OverviewFilesCommitsIssuesDocsReleasesPeers

pearbrowser-desktop commits / a27c3a7

Add Pear v3 native app launcher
a27c3a73e8cae836987df6865aad227168e573d3
by bigdestiny2 <33146784+bigdestiny2@users.noreply.github.com> · 2026-07-29T20:50:32.000Z

Changed files (16) +1108 -149

README.md +4 -3
……@@ -52,8 +52,9 @@ your platform, and use the migration guidance in
5252
5353### Apps
5454- Open `hyper://` sites in a browser tab.
55- A compatible AppRelease v2 desktop package is a separate, verified install action; it is not a tab-runtime input.
56- A `pear://` or `file://` row is a **legacy migration record**. PearBrowser displays migration guidance and never launches it.
55- Install an explicitly configured Pear v3 build through a host-confirmed native action. Catalogue rows use `nativeDelivery: { status: "available", kind: "pear-v3", installLink: "pear://…" }`; the installed app owns its own runtime, storage, windows, and OTA lifecycle.
56- A top-level `pear://` or `file://` row remains a **legacy migration record**. PearBrowser never sends catalogue values to `PearRuntime.run()`.
57- Native Pear apps and installed Hyperdrive sites are tracked separately: sites launch through the tab proxy, while native apps launch through the operating system.
5758- Load decentralized catalogues from Hyperdrive JSON, signed Hyperbee, Autobee, schema-sheets rooms, HiveRelay index rooms, default curated seeds, community submissions, and your own writable catalogues
5859- Keep multiple catalogs loaded at once with search, category, and source filters across the aggregated app store
5960- **My Catalog:** create a writable personal catalog, add apps from loaded catalogs or installed apps, rename it, edit saved metadata (name, description, version, author, categories), and share the catalog key; copies opened without the writer key stay read-only
……@@ -163,7 +164,7 @@ npm run start # fails closed until the embedded v3 runtime host is configured
163164
164165Source installs are standalone. The desktop packages default to npm `latest` for HiveRelay; the root package defaults to npm `latest` for `p2p-hiverelay`, `p2p-hiverelay-client`, and `p2p-hiverelay-verifier`, with the current dist-tag resolving to `0.20.2` in the lockfile, so a clone of just this repo resolves them from the registry. `npm install` runs `scripts/check-hiverelay-layout.mjs`, which exits quietly for the registry line and fails only if the HiveRelay dependency/lockfile line drifts or you opt into incomplete/mismatched `file:` workspace dependencies. A sibling `../../00-core/hiverelay` checkout is optional and only needed for HiveRelay co-development.
165166
166UI files use htm + React (no build step). Backend in `backend/` is CommonJS. The source checkout deliberately refuses to start through the removed v2 launcher until the v3 embedded-runtime host is complete; it does not accept remote app links as worker input.
167UI files use htm + React (no build step). Backend in `backend/` is CommonJS. The source checkout starts through the native Electron + embedded Pear v3 host; it does not accept remote app links as worker input. Third-party Pear v3 builds install through the narrow Electron-main `pear-install` boundary and then launch as ordinary OS applications.
167168
168169## Release pipeline
169170
backend/catalog-manager.js +5 -3
……@@ -625,8 +625,9 @@ class CatalogManager {
625625 const drive = await this._ensureMyCatalogDrive(keyHex)
626626 if (!drive.writable) throw new Error('This catalog is not editable on this device.')
627627 const entry = this._sanitizeCatalogEntry(app)
628 if (!entry.driveKey && !entry.link) throw new Error('App is missing a valid Hyperdrive key or hyper:// link.')
629 entry.id = entry.id || entry.driveKey || entry.link
628 const nativeInstallLink = entry.nativeDelivery?.status === 'available' ? entry.nativeDelivery.installLink : ''
629 if (!entry.driveKey && !entry.link && !nativeInstallLink) throw new Error('App is missing browsable content or a Pear v3 native delivery link.')
630 entry.id = entry.id || entry.driveKey || entry.link || nativeInstallLink
630631
631632 const data = await this._readMyCatalogData(drive)
632633 const idx = data.apps.findIndex((a) => a && a.id === entry.id)
……@@ -664,7 +665,8 @@ class CatalogManager {
664665 const existing = data.apps[idx]
665666 const stableId = existing.id || appId
666667 const entry = this._sanitizeCatalogEntry({ ...existing, ...(patch || {}), id: stableId })
667 if (!entry.driveKey && !entry.link) throw new Error('App is missing a valid Hyperdrive key or hyper:// link.')
668 const nativeInstallLink = entry.nativeDelivery?.status === 'available' ? entry.nativeDelivery.installLink : ''
669 if (!entry.driveKey && !entry.link && !nativeInstallLink) throw new Error('App is missing browsable content or a Pear v3 native delivery link.')
668670 entry.id = stableId
669671 data.apps[idx] = entry
670672
backend/catalog-safety.cjs +36 -5
……@@ -26,6 +26,7 @@ function catalogAppsFromEnvelope (catalog) {
2626
2727const HEX64_RE = /^[0-9a-f]{64}$/i
2828const Z32_RE = /^[13-9a-km-uw-z]{52}$/i
29const PEAR_V3_LINK_RE = /^pear:\/\/[13-9a-km-uw-z]{52}\/?$/i
2930// Catalog links are browsable content only. Native delivery must be represented
3031// separately as a verified package or a non-executable migration record.
3132const APP_LINK_RE = /^hyper:\/\/.+/i
……@@ -99,8 +100,29 @@ function normalizeLegacyMigrationId (value) {
99100}
100101
101102function normalizeNativeDelivery (value) {
102 const status = trimString(value && typeof value === 'object' ? value.status : value)
103 return NATIVE_DELIVERY_STATUSES.has(status) ? { status } : null
103 const source = value && typeof value === 'object' ? value : { status: value }
104 const status = trimString(source.status)
105 if (!NATIVE_DELIVERY_STATUSES.has(status)) return null
106 if (status === 'migration-required') return { status }
107
108 const kind = trimString(source.kind).toLowerCase()
109 const rawLink = trimString(source.installLink || source.link)
110 const installLink = PEAR_V3_LINK_RE.test(rawLink)
111 ? rawLink.replace(/\/$/, '').replace(/^PEAR:\/\//i, 'pear://').toLowerCase()
112 : ''
113 if (kind !== 'pear-v3' || !installLink) return null
114
115 const productName = trimString(source.productName).slice(0, 120)
116 const targets = Array.isArray(source.targets)
117 ? source.targets.map((target) => trimString(target).toLowerCase()).filter((target) => /^[a-z0-9_-]{3,40}$/.test(target)).slice(0, 12)
118 : []
119 return {
120 status,
121 kind,
122 installLink,
123 ...(productName ? { productName } : {}),
124 ...(targets.length ? { targets } : {})
125 }
104126}
105127
106128function normalizeCatalogApp (app, opts = {}) {
……@@ -117,8 +139,9 @@ function normalizeCatalogApp (app, opts = {}) {
117139 const driveKey = normalizeDriveKey(key) || driveKeyFromHyperLink(link)
118140 const legacyMigrationId = normalizeLegacyMigrationId(out.legacyMigrationId)
119141 const nativeDelivery = normalizeNativeDelivery(out.nativeDelivery)
120 const id = trimString(out.id) || driveKey || link || (legacyMigrationId ? `legacy:${legacyMigrationId}` : '')
121 if ((key && !driveKey && !link) || (rawLink && !link && !driveKey) || (!driveKey && !link && !legacyMigrationId) || !id) return null
142 const nativeInstallLink = nativeDelivery && nativeDelivery.status === 'available' ? nativeDelivery.installLink : ''
143 const id = trimString(out.id) || driveKey || link || nativeInstallLink || (legacyMigrationId ? `legacy:${legacyMigrationId}` : '')
144 if ((key && !driveKey && !link && !legacyMigrationId && !nativeInstallLink) || (rawLink && !link && !driveKey && !legacyMigrationId && !nativeInstallLink) || (!driveKey && !link && !legacyMigrationId && !nativeInstallLink) || !id) return null
122145
123146 out.id = id || undefined
124147 out.name = trimString(out.name) || id || undefined
……@@ -164,6 +187,8 @@ function catalogAppStableKey (app) {
164187 const driveKey = normalizeDriveKey(key) || driveKeyFromHyperLink(link)
165188 if (driveKey) return `drive:${driveKey}`
166189 if (link) return `link:${link}`
190 const nativeDelivery = normalizeNativeDelivery(app.nativeDelivery)
191 if (nativeDelivery && nativeDelivery.status === 'available') return `native:${nativeDelivery.installLink}`
167192 const legacyMigrationId = normalizeLegacyMigrationId(app.legacyMigrationId)
168193 if (legacyMigrationId) return `legacy:${legacyMigrationId}`
169194 const id = trimString(app.id)
……@@ -256,6 +281,10 @@ function catalogAppSearchText (app) {
256281 app.verification,
257282 app.legacyMigrationId,
258283 app.nativeDelivery && app.nativeDelivery.status,
284 app.nativeDelivery && app.nativeDelivery.kind,
285 app.nativeDelivery && app.nativeDelivery.installLink,
286 app.nativeDelivery && app.nativeDelivery.productName,
287 ...(Array.isArray(app.nativeDelivery && app.nativeDelivery.targets) ? app.nativeDelivery.targets : []),
259288 app.link,
260289 app.driveKey,
261290 ...(Array.isArray(app.categories) ? app.categories : []),
……@@ -279,6 +308,7 @@ function sanitizePersonalCatalogEntry (app) {
279308 version: str(app.version, 40),
280309 author: str(app.author, 200),
281310 icon: str(app.icon, 300),
311 nativeDelivery: normalizeNativeDelivery(app.nativeDelivery),
282312 // Launch gating (PBACS §9): explicit standalone (own window) vs hypersite
283313 // (inline tab). Only the two valid enum values survive; anything else drops.
284314 type: (['standalone', 'hypersite'].includes(String(app.type || '').trim()) ? String(app.type).trim() : undefined)
……@@ -288,7 +318,7 @@ function sanitizePersonalCatalogEntry (app) {
288318 }
289319 for (const k of Object.keys(draft)) if (draft[k] === undefined || draft[k] === '') delete draft[k]
290320 const out = normalizeCatalogApp(draft)
291 if (!out) throw new Error('App needs a valid 64-hex drive key or hyper:// drive link.')
321 if (!out) throw new Error('App needs a valid 64-hex drive key, browsable hyper:// content, a legacy migration id, or a configured Pear v3 native delivery link.')
292322 return {
293323 id: out.id,
294324 name: out.name,
……@@ -296,6 +326,7 @@ function sanitizePersonalCatalogEntry (app) {
296326 description: out.description || '',
297327 ...(out.driveKey ? { driveKey: out.driveKey } : {}),
298328 ...(out.link ? { link: out.link } : {}),
329 ...(out.nativeDelivery ? { nativeDelivery: out.nativeDelivery } : {}),
299330 version: out.version || '',
300331 author: out.author || '',
301332 categories: Array.isArray(out.categories) ? out.categories : [],
backend/sheets-catalog.js +22 -2
……@@ -32,6 +32,10 @@ const APPS_SCHEMA = {
3232 link: { type: 'string', maxLength: 300 },
3333 legacyMigrationId: { type: 'string', pattern: '^[13-9a-km-uw-z]{52}$' },
3434 nativeDeliveryStatus: { enum: ['migration-required', 'available'] },
35 nativeDeliveryKind: { enum: ['pear-v3'] },
36 nativeInstallLink: { type: 'string', pattern: '^pear://[13-9a-km-uw-z]{52}/?$' },
37 nativeProductName: { type: 'string', maxLength: 120 },
38 nativeTargets: { type: 'array', items: { type: 'string', maxLength: 40 }, maxItems: 12 },
3539 iconData: { type: 'string', maxLength: 20000 },
3640 type: { enum: ['standalone', 'hypersite'] },
3741 author: { type: 'string', maxLength: 200 },
……@@ -45,7 +49,15 @@ const APPS_SCHEMA = {
4549 verification: { enum: ['unverified', 'relay-listed', 'author-signed'] }
4650 },
4751 required: ['name', 'type'],
48 anyOf: [{ required: ['driveKey'] }, { required: ['link'] }, { required: ['legacyMigrationId'] }],
52 anyOf: [
53 { required: ['driveKey'] },
54 { required: ['link'] },
55 { required: ['legacyMigrationId'] },
56 {
57 properties: { nativeDeliveryStatus: { const: 'available' } },
58 required: ['nativeDeliveryStatus', 'nativeDeliveryKind', 'nativeInstallLink']
59 }
60 ],
4961 additionalProperties: false
5062}
5163
……@@ -117,7 +129,15 @@ function rowToApp (row) {
117129 driveKey: j.driveKey || null,
118130 link: j.link || null,
119131 legacyMigrationId: j.legacyMigrationId || null,
120 nativeDelivery: j.nativeDeliveryStatus ? { status: j.nativeDeliveryStatus } : null,
132 nativeDelivery: j.nativeDeliveryStatus
133 ? {
134 status: j.nativeDeliveryStatus,
135 kind: j.nativeDeliveryKind,
136 installLink: j.nativeInstallLink,
137 productName: j.nativeProductName,
138 targets: j.nativeTargets
139 }
140 : null,
121141 type: j.type || (j.link && !j.driveKey ? 'standalone' : undefined),
122142 author: j.author || null,
123143 homepage: j.homepage || null,
docs/PEARBROWSER-APP-COMPAT-STANDARD.md +5 -2
……@@ -596,7 +596,8 @@ application.
596596- **PB-RELEASE-2 (MUST).** The embedded runtime starts only a bundled local
597597 entrypoint (for example `PearRuntime.run(require.resolve('./worker.js'))`).
598598 A catalogue link, release identifier, or remote path must never become a
599 runtime argument.
599 runtime argument. A v3 catalogue install uses `pear-install`; after OS
600 installation, that application configures and starts its own runtime.
600601- **PB-RELEASE-3 (MUST).** Test clean install, upgrade, data preservation,
601602 rollback, and local worker boot/shutdown for every supported target. A
602603 checksum alone is insufficient release evidence.
……@@ -615,7 +616,9 @@ application.
615616### 13.3 Catalogue actions
616617
617618- **PB-RELEASE-6 (MUST).** A `hyper://` entry is browsable content. A compatible
618 signed AppRelease v2 record is an explicit native install action. A
619 Pear v3 build is represented separately as
620 `nativeDelivery:{status:'available',kind:'pear-v3',installLink:'pear://…'}`
621 and is an explicit, host-confirmed native install action. A top-level
619622 `pear://` or `file://` entry is legacy discovery metadata and must display
620623 **migration required**, never an install or run action.
621624- **PB-RELEASE-7 (MUST).** Catalogue records include name, description, author,
docs/PEAR_V3_MIGRATION.md +39 -8
……@@ -7,11 +7,31 @@ that presents verified applications and installs as distinct user actions.
77
88- Install PearBrowser from a native package and verify the published checksum.
99- Use `hyper://` drives as browsable content or sites.
10- Treat old `pear://` app links as legacy migration identifiers. They do not
11 execute in PearBrowser v3 and they are never supplied to `PearRuntime.run()`.
12- Install a compatible app only when its signed AppRelease v2 package and
13 platform target have been verified. A legacy-only app displays **Legacy app —
14 migration required**.
10- Treat old top-level `pear://` catalogue links as legacy migration identifiers.
11 They do not execute in PearBrowser v3 and are never supplied to
12 `PearRuntime.run()`.
13- Install a Pear v3 build only from an explicit catalogue
14 `nativeDelivery` block whose kind is `pear-v3` and whose `installLink` is a
15 canonical root `pear://<52-character-key>` link. Installation is a separate,
16 host-confirmed action through `pear-install`; it is never tab navigation.
17- A legacy-only app displays **Legacy app — migration required**. A verified
18 publisher/AppRelease attestation remains catalogue trust evidence; it does
19 not turn a legacy runtime link into a v3 build.
20
21An available native catalogue entry uses this shape (with the real published
22key and only the OS/architecture targets actually present in the build):
23
24```json
25{
26 "nativeDelivery": {
27 "status": "available",
28 "kind": "pear-v3",
29 "installLink": "pear://<52-character-key>",
30 "productName": "Example App",
31 "targets": ["darwin-arm64", "linux-x64", "win32-x64"]
32 }
33}
34```
1535
1636## Runtime boundary
1737
……@@ -22,9 +42,20 @@ only the bundled `workers/main.js` through
2242the authenticated localhost RPC service.
2343
2444The renderer receives a freshly generated, per-launch session token through
25the context-isolated preload bridge. It cannot invoke the runtime, install an
26application, or obtain Node integration. A remote catalogue link remains
27discovery metadata, never a `PearRuntime.run()` input.
45the context-isolated preload bridge. It cannot invoke the runtime or obtain
46Node integration. The preload exposes narrow native-app list/install/launch
47operations; Electron main validates the renderer, shows the OS-level install
48confirmation, and accepts no caller-controlled destination, binary filter, or
49DHT bootstrap configuration. A remote catalogue link remains discovery
50metadata, never a `PearRuntime.run()` input.
51
52The native launcher pins `pear-install@1.2.2`. It accepts one GUI artifact for
53the current OS (`.app`, `.AppImage`, or `.msix`), rejects packages exposing
54command-line binary targets, requires the package's `upgrade` identity to match
55the requested link and optional catalogue product name, and rejects unexpected
56install destinations or an incompatible declared platform target. The installed
57application then starts and configures its own embedded `pear-runtime`; the
58browser does not provide an ambient `Pear` global or runtime configuration.
2859
2960The `package.json#upgrade` field is a v3 OTA deployment identity, not a
3061browser-launch affordance. It must move to the project’s production multisig
electron/main.cjs +76 -1
……@@ -1,18 +1,34 @@
11// Native v3 host. Electron owns application lifecycle and Pear OTA owns only
22// the embedded Bare backend/update worker; there is no `pear run` launch path.
3const { app, BrowserWindow, ipcMain } = require('electron')
3const { app, BrowserWindow, dialog, ipcMain, shell } = require('electron')
44const crypto = require('node:crypto')
55const os = require('node:os')
66const path = require('node:path')
7const { pathToFileURL } = require('node:url')
78const PearRuntime = require('pear-runtime')
89const pkg = require('../package.json')
10const { PearAppLauncher, normalizePearInstallLink } = require('./pear-app-launcher.cjs')
911
1012const sessionToken = crypto.randomBytes(32).toString('hex')
1113const appName = pkg.productName || pkg.name
1214let workerPipe = null
1315let pearRuntime = null
16let pearAppLauncher = null
1417let quitting = false
1518
19function requireTrustedRenderer (event) {
20 const senderUrl = String(event?.senderFrame?.url || event?.sender?.getURL?.() || '')
21 const shellUrl = pathToFileURL(path.join(__dirname, '..', 'index.html')).href
22 if (senderUrl !== shellUrl) {
23 throw new Error('Pear app operation rejected from an untrusted renderer')
24 }
25}
26
27function requirePearAppLauncher () {
28 if (!pearAppLauncher) throw new Error('Pear v3 app launcher is not ready')
29 return pearAppLauncher
30}
31
1632ipcMain.on('pearbrowser:runtime-session', (event) => {
1733 event.returnValue = sessionToken
1834})
……@@ -21,6 +37,61 @@ ipcMain.handle('pearbrowser:open-devtools', (event) => {
2137 event.sender.openDevTools({ mode: 'detach' })
2238})
2339
40ipcMain.handle('pearbrowser:pear-apps:list', (event) => {
41 requireTrustedRenderer(event)
42 return requirePearAppLauncher().list()
43})
44
45ipcMain.handle('pearbrowser:pear-apps:install', async (event, request = {}) => {
46 requireTrustedRenderer(event)
47 const nativeDelivery = request && typeof request.nativeDelivery === 'object' ? request.nativeDelivery : {}
48 if (nativeDelivery.status !== 'available' || nativeDelivery.kind !== 'pear-v3') {
49 throw new Error('Catalogue entry is not configured as an available Pear v3 native app')
50 }
51 const link = normalizePearInstallLink(nativeDelivery.installLink)
52 const hostTarget = `${process.platform}-${process.arch}`
53 const targets = Array.isArray(nativeDelivery.targets)
54 ? nativeDelivery.targets.map((target) => String(target || '').trim().toLowerCase())
55 : []
56 if (targets.some((target) => !/^[a-z0-9_-]{3,40}$/.test(target))) {
57 throw new Error('Catalogue entry contains an invalid Pear v3 platform target')
58 }
59 if (targets.length && !targets.includes(hostTarget)) {
60 throw new Error(`This Pear v3 app is not published for ${hostTarget}`)
61 }
62 const name = String(request.name || request.id || 'Pear app').replace(/[\u0000-\u001f\u007f]/g, '').slice(0, 200) || 'Pear app'
63 const claimedVerification = String(request.verification || '').trim()
64 const verification = ['author-signed', 'relay-listed'].includes(claimedVerification) ? claimedVerification : 'unverified'
65 const parent = BrowserWindow.fromWebContents(event.sender)
66 const choice = await dialog.showMessageBox(parent || undefined, {
67 type: 'warning',
68 title: `Install ${name}`,
69 message: `Install ${name} as a native application?`,
70 detail: `${link}\n\nCatalogue claim: ${verification}. PearBrowser validates the v3 bundle shape and OS target, but does not independently verify catalogue authorship during installation. The application will run outside the tab sandbox and will manage its own Pear v3 runtime, storage, and updates.`,
71 buttons: ['Cancel', 'Install native app'],
72 defaultId: 0,
73 cancelId: 0,
74 noLink: true
75 })
76 if (choice.response !== 1) return { cancelled: true }
77
78 return await requirePearAppLauncher().install({
79 id: request.id,
80 name,
81 link,
82 productName: nativeDelivery.productName,
83 verification
84 }, (progress) => {
85 if (!event.sender.isDestroyed()) event.sender.send('pearbrowser:pear-apps:progress', { link, ...progress })
86 })
87})
88
89ipcMain.handle('pearbrowser:pear-apps:launch', async (event, request = {}) => {
90 requireTrustedRenderer(event)
91 const target = request.link || request.id
92 return await requirePearAppLauncher().launch(target)
93})
94
2495function storagePath () {
2596 if (!app.isPackaged) return path.join(os.tmpdir(), 'pearbrowser-desktop')
2697 return app.getPath('userData')
……@@ -82,6 +153,10 @@ if (!singleInstance) {
82153 app.quit()
83154} else {
84155 app.whenReady().then(async () => {
156 pearAppLauncher = new PearAppLauncher({
157 dataFile: path.join(app.getPath('userData'), 'pear-v3-apps.json'),
158 shell
159 })
85160 await startBackendWorker()
86161 await createWindow()
87162 app.on('activate', async () => {
electron/pear-app-launcher.cjs +358 -0
……@@ -0,0 +1,358 @@
1'use strict'
2
3const crypto = require('node:crypto')
4const fs = require('node:fs')
5const os = require('node:os')
6const path = require('node:path')
7const { spawn, spawnSync } = require('node:child_process')
8
9const PEAR_KEY_RE = /^[13-9a-km-uw-z]{52}$/
10const PEAR_VERSION_LINK_RE = /^pear:\/\/(?:[13-9a-km-uw-z]{52}|\d+\.\d+\.[13-9a-km-uw-z]{52}(?:\.[13-9a-km-uw-z]{52})?)$/
11const SAFE_APP_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9 ._()+@-]{0,119}$/
12const SAFE_PACKAGE_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,199}$/
13
14function normalizePearInstallLink (value) {
15 const raw = String(value || '').trim()
16 let parsed
17 try {
18 parsed = new URL(raw)
19 } catch {
20 throw new Error('Pear v3 install link must be a valid pear:// URL')
21 }
22 const host = String(parsed.hostname || '').toLowerCase()
23 if (parsed.protocol !== 'pear:' || !PEAR_KEY_RE.test(host)) {
24 throw new Error('Pear v3 install link must contain a canonical 52-character Pear key')
25 }
26 if ((parsed.pathname && parsed.pathname !== '/') || parsed.search || parsed.hash || parsed.username || parsed.password || parsed.port) {
27 throw new Error('Pear v3 install link must not contain a path, query, fragment, credentials, or port')
28 }
29 return `pear://${host}`
30}
31
32function safeCatalogId (value, fallback) {
33 const id = String(value || '').trim()
34 if (!id) return fallback
35 if (id.length > 200 || /[\u0000-\u001f\u007f]/.test(id)) throw new Error('Invalid catalogue app id')
36 return id
37}
38
39function safeDisplayName (value, fallback) {
40 const name = String(value || '').trim()
41 if (!name) return fallback
42 if (name.length > 200 || /[\u0000-\u001f\u007f]/.test(name)) throw new Error('Invalid catalogue app name')
43 return name
44}
45
46function guiExtension (platform) {
47 if (platform === 'darwin') return '.app'
48 if (platform === 'linux') return '.AppImage'
49 if (platform === 'win32') return '.msix'
50 throw new Error(`Pear v3 native apps are not supported on ${platform}`)
51}
52
53function allowedLinuxRoots (homeDir) {
54 return [
55 path.join(homeDir, 'Applications'),
56 path.join(homeDir, 'AppImages'),
57 path.join(homeDir, '.local', 'bin')
58 ]
59}
60
61function validateResolvedTargets (targets, opts = {}) {
62 const platform = opts.platform || process.platform
63 const homeDir = opts.homeDir || os.homedir()
64 const extension = guiExtension(platform)
65 if (!Array.isArray(targets) || targets.length !== 1) {
66 throw new Error('Pear package contains multiple installable targets; browser installs support one native application only')
67 }
68
69 const target = targets[0]
70 const app = String(target && target.filename || '').trim()
71 const ext = String(target && target.ext || '')
72 const dest = target && target.dest == null ? null : path.resolve(String(target.dest))
73 if (!SAFE_APP_NAME_RE.test(app) || target.isBin === true || ext !== extension) {
74 throw new Error('Pear package contains a non-GUI executable target; browser installs support one native application only')
75 }
76 if (platform === 'darwin' && dest !== path.join('/Applications', app + extension)) {
77 throw new Error('Pear package requested an unexpected macOS install destination')
78 }
79 if (platform === 'linux' && (!dest || path.basename(dest) !== app + extension || !allowedLinuxRoots(homeDir).includes(path.dirname(dest)))) {
80 throw new Error('Pear package requested an unexpected Linux install destination')
81 }
82 if (platform === 'win32' && dest !== null) {
83 throw new Error('Pear package requested an unexpected Windows install destination')
84 }
85
86 return { app, ext, dest }
87}
88
89function validateInstallEvent (data, opts = {}) {
90 const platform = opts.platform || process.platform
91 const arch = opts.arch || null
92 const homeDir = opts.homeDir || os.homedir()
93 const expectedLink = opts.expectedLink ? normalizePearInstallLink(opts.expectedLink) : null
94 if (!data || typeof data !== 'object') throw new Error('Pear installer returned invalid application metadata')
95
96 const app = String(data.app || '').trim()
97 const packageName = String(data.name || '').trim()
98 const extension = guiExtension(platform)
99 if (!SAFE_APP_NAME_RE.test(app) || app === '.' || app === '..') {
100 throw new Error('Pear package has an unsafe native application name')
101 }
102 if (!SAFE_PACKAGE_NAME_RE.test(packageName)) throw new Error('Pear package has an unsafe package name')
103 if (opts.expectedAppName && app !== String(opts.expectedAppName).trim()) {
104 throw new Error('Pear package product name does not match the catalogue entry')
105 }
106
107 const key = String(data.key || '')
108 const expectedSuffix = `/${app}${extension}`
109 const expectedKey = arch && /^[a-z0-9_-]{2,40}$/.test(arch) ? `/by-arch/${platform}-${arch}/app/${app}${extension}` : null
110 if ((expectedKey && key !== expectedKey) || (!expectedKey && (!key.startsWith('/by-arch/') || !key.endsWith(expectedSuffix)))) {
111 throw new Error('Pear package contains a non-GUI executable target; browser installs support one native application only')
112 }
113
114 const upgrade = normalizePearInstallLink(data.upgrade)
115 if (expectedLink && upgrade !== expectedLink) {
116 throw new Error('Pear package upgrade identity does not match the requested install link')
117 }
118
119 let dest = data.dest == null ? null : path.resolve(String(data.dest))
120 if (platform === 'darwin') {
121 const expected = path.join('/Applications', app + extension)
122 if (dest !== expected) throw new Error('Pear package requested an unexpected macOS install destination')
123 } else if (platform === 'linux') {
124 if (!dest || path.basename(dest) !== app + extension || !allowedLinuxRoots(homeDir).includes(path.dirname(dest))) {
125 throw new Error('Pear package requested an unexpected Linux install destination')
126 }
127 } else if (platform === 'win32' && dest !== null) {
128 throw new Error('Pear package requested an unexpected Windows install destination')
129 }
130
131 const version = String(data.version || '').trim()
132 if (!version || version.length > 80 || /[\u0000-\u001f\u007f]/.test(version)) {
133 throw new Error('Pear package has an invalid version')
134 }
135 const verlink = String(data.verlink || '').trim()
136 if (!PEAR_VERSION_LINK_RE.test(verlink)) throw new Error('Pear package has an invalid version link')
137
138 return { app, packageName, version, upgrade, verlink, key, dest }
139}
140
141function sanitizeProgress (stats) {
142 const download = stats && stats.download
143 const upload = stats && stats.upload
144 return {
145 peers: Number.isFinite(Number(stats && stats.peers)) ? Number(stats.peers) : 0,
146 download: {
147 bytes: Number.isFinite(Number(download && download.bytes)) ? Number(download.bytes) : 0,
148 speed: Number.isFinite(Number(download && download.speed)) ? Number(download.speed) : 0
149 },
150 upload: {
151 bytes: Number.isFinite(Number(upload && upload.bytes)) ? Number(upload.bytes) : 0,
152 speed: Number.isFinite(Number(upload && upload.speed)) ? Number(upload.speed) : 0
153 }
154 }
155}
156
157class PearAppLauncher {
158 constructor (opts = {}) {
159 this.platform = opts.platform || process.platform
160 this.arch = opts.arch || process.arch
161 this.homeDir = opts.homeDir || os.homedir()
162 this.dataFile = opts.dataFile || null
163 this.Install = opts.Install || null
164 this.shell = opts.shell || null
165 this.fs = opts.fs || fs
166 this.spawn = opts.spawn || spawn
167 this.spawnSync = opts.spawnSync || spawnSync
168 this.records = new Map()
169 this._load()
170 }
171
172 _load () {
173 if (!this.dataFile) return
174 try {
175 const parsed = JSON.parse(this.fs.readFileSync(this.dataFile, 'utf8'))
176 for (const record of Array.isArray(parsed && parsed.apps) ? parsed.apps : []) {
177 if (!record || typeof record !== 'object') continue
178 try {
179 const link = normalizePearInstallLink(record.link)
180 const installed = validateInstallEvent({
181 app: record.app,
182 name: record.packageName,
183 version: record.version,
184 upgrade: record.upgrade,
185 verlink: record.verlink,
186 key: record.artifactKey,
187 dest: record.dest
188 }, {
189 platform: record.platform,
190 arch: record.arch,
191 homeDir: this.homeDir,
192 expectedLink: link
193 })
194 this.records.set(link, { ...record, ...installed, artifactKey: installed.key, link })
195 } catch {}
196 }
197 } catch (err) {
198 if (err && err.code !== 'ENOENT') console.error('Failed to read Pear v3 app records:', err.message)
199 }
200 }
201
202 _save () {
203 if (!this.dataFile) return
204 const dir = path.dirname(this.dataFile)
205 const tmp = `${this.dataFile}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`
206 this.fs.mkdirSync(dir, { recursive: true })
207 this.fs.writeFileSync(tmp, JSON.stringify({ version: 1, apps: [...this.records.values()] }, null, 2) + '\n', { mode: 0o600 })
208 this.fs.renameSync(tmp, this.dataFile)
209 }
210
211 _installClass () {
212 if (this.Install) return this.Install
213 // Lazy-load so source-contract tests and unsupported platforms do not
214 // initialize installer networking merely by importing this module.
215 return require('pear-install')
216 }
217
218 list () {
219 return [...this.records.values()].map((record) => ({
220 ...record,
221 installed: this._isInstalled(record)
222 }))
223 }
224
225 find (value) {
226 const raw = String(value || '').trim()
227 if (!raw) return null
228 try {
229 return this.records.get(normalizePearInstallLink(raw)) || null
230 } catch {}
231 for (const record of this.records.values()) if (record.id === raw) return record
232 return null
233 }
234
235 async install (request, onEvent = () => {}) {
236 if (!request || typeof request !== 'object') throw new Error('Pear v3 app metadata is required')
237 const link = normalizePearInstallLink(request.link)
238 const id = safeCatalogId(request.id, link)
239 const displayName = safeDisplayName(request.name, id)
240 const existing = this.records.get(link)
241 if (existing && this._isInstalled(existing)) return { ...existing, installed: true, exists: true }
242
243 const Install = this._installClass()
244 const installer = new Install({ link })
245 // pear-install 1.2.2 resolves the complete target set before this method is
246 // called. Guard that boundary so a package containing an already-present
247 // CLI target cannot bypass the per-artifact `app` event validation below.
248 if (typeof installer._partitionTargets === 'function') {
249 const partitionTargets = installer._partitionTargets
250 installer._partitionTargets = (targets, packageName) => {
251 validateResolvedTargets(targets, { platform: this.platform, homeDir: this.homeDir })
252 return partitionTargets.call(installer, targets, packageName)
253 }
254 }
255 let application = null
256 let final = null
257 installer.on('installing', (data) => onEvent({ phase: 'connecting', link, host: String(data && data.host || '') }))
258 installer.on('stats', (data) => onEvent({ phase: 'downloading', ...sanitizeProgress(data) }))
259 installer.on('app', (data) => {
260 if (application) throw new Error('Pear package contains multiple installable targets')
261 application = validateInstallEvent(data, {
262 platform: this.platform,
263 arch: this.arch,
264 homeDir: this.homeDir,
265 expectedLink: link,
266 expectedAppName: request.productName
267 })
268 onEvent({ phase: 'installing', app: application.app, version: application.version })
269 })
270 installer.on('final', (data) => { final = data })
271
272 try {
273 await installer.ready()
274 } finally {
275 await installer.close()
276 }
277 if (Array.isArray(installer.targets) && installer.targets.length) {
278 validateResolvedTargets(installer.targets, { platform: this.platform, homeDir: this.homeDir })
279 }
280 if (!application || !final || final.success !== true || Number(final.installed) !== 1) {
281 throw new Error('Pear installer did not install exactly one native application')
282 }
283
284 const record = {
285 id,
286 displayName,
287 link,
288 app: application.app,
289 packageName: application.packageName,
290 version: application.version,
291 upgrade: application.upgrade,
292 verlink: application.verlink,
293 artifactKey: application.key,
294 dest: application.dest,
295 platform: this.platform,
296 arch: this.arch,
297 verification: String(request.verification || 'unverified').slice(0, 40),
298 installedAt: Date.now()
299 }
300 this.records.set(link, record)
301 this._save()
302 onEvent({ phase: 'complete', app: record.app, version: record.version })
303 return { ...record, installed: true, exists: false }
304 }
305
306 async launch (value) {
307 const record = this.find(value)
308 if (!record) throw new Error('Pear v3 app is not registered as installed')
309 if (record.platform !== this.platform || record.arch !== this.arch) throw new Error('Pear v3 app was installed for a different operating system or architecture')
310 if (!this._isInstalled(record)) throw new Error('Pear v3 app is no longer installed at its recorded OS location')
311
312 if (this.platform === 'darwin') {
313 if (!this.shell || typeof this.shell.openPath !== 'function') throw new Error('macOS application launcher is unavailable')
314 const error = await this.shell.openPath(record.dest)
315 if (error) throw new Error(`Could not launch ${record.app}: ${error}`)
316 } else if (this.platform === 'linux') {
317 const child = this.spawn(record.dest, [], { detached: true, stdio: 'ignore' })
318 child.unref()
319 } else if (this.platform === 'win32') {
320 const env = { ...process.env, PEARBROWSER_PEAR_PACKAGE: record.packageName }
321 const script = [
322 "$pkg = Get-AppxPackage -Name $env:PEARBROWSER_PEAR_PACKAGE -ErrorAction Stop | Select-Object -First 1",
323 "$app = Get-StartApps | Where-Object { $_.AppID -like ($pkg.PackageFamilyName + '!*') } | Select-Object -First 1",
324 "if ($null -eq $app) { throw 'Installed application entry point not found' }",
325 'Start-Process (\'shell:AppsFolder\\\' + $app.AppID)'
326 ].join('; ')
327 const result = this.spawnSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], { env, windowsHide: true })
328 if (result.status !== 0) {
329 const message = String(result.stderr || '').trim() || `PowerShell exited ${result.status}`
330 throw new Error(`Could not launch ${record.app}: ${message}`)
331 }
332 }
333 return { ...record, launched: true }
334 }
335
336 _isInstalled (record) {
337 if (!record || record.platform !== this.platform || record.arch !== this.arch) return false
338 if (this.platform === 'darwin' || this.platform === 'linux') {
339 return !!record.dest && this.fs.existsSync(record.dest)
340 }
341 if (this.platform === 'win32') {
342 if (!SAFE_PACKAGE_NAME_RE.test(String(record.packageName || ''))) return false
343 const env = { ...process.env, PEARBROWSER_PEAR_PACKAGE: record.packageName }
344 const script = "$null -ne (Get-AppxPackage -Name $env:PEARBROWSER_PEAR_PACKAGE -ErrorAction SilentlyContinue)"
345 const result = this.spawnSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], { env, windowsHide: true })
346 return result.status === 0 && String(result.stdout || '').trim() === 'True'
347 }
348 return false
349 }
350}
351
352module.exports = {
353 PearAppLauncher,
354 normalizePearInstallLink,
355 validateInstallEvent,
356 validateResolvedTargets,
357 guiExtension
358}
electron/preload.cjs +10 -1
……@@ -2,5 +2,14 @@ const { contextBridge, ipcRenderer } = require('electron')
22
33contextBridge.exposeInMainWorld('pearbrowserRuntime', {
44 sessionToken: ipcRenderer.sendSync('pearbrowser:runtime-session'),
5 openDevTools: () => ipcRenderer.invoke('pearbrowser:open-devtools')
5 openDevTools: () => ipcRenderer.invoke('pearbrowser:open-devtools'),
6 listPearApps: () => ipcRenderer.invoke('pearbrowser:pear-apps:list'),
7 installPearApp: (app) => ipcRenderer.invoke('pearbrowser:pear-apps:install', app),
8 launchPearApp: (target) => ipcRenderer.invoke('pearbrowser:pear-apps:launch', target),
9 onPearAppProgress: (callback) => {
10 if (typeof callback !== 'function') return () => {}
11 const listener = (_event, data) => callback(data)
12 ipcRenderer.on('pearbrowser:pear-apps:progress', listener)
13 return () => ipcRenderer.removeListener('pearbrowser:pear-apps:progress', listener)
14 }
615})
package-lock.json +120 -104
……@@ -39,6 +39,7 @@
3939 "p2p-hiverelay": "latest",
4040 "p2p-hiverelay-client": "latest",
4141 "p2p-hiverelay-verifier": "latest",
42 "pear-install": "1.2.2",
4243 "pear-runtime": "1.3.1",
4344 "react": "^18.3.1",
4445 "react-dom": "^18.3.1",
……@@ -1674,22 +1675,19 @@
16741675 }
16751676 },
16761677 "node_modules/bare-os": {
1677 "version": "3.8.7",
1678 "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.8.7.tgz",
1679 "integrity": "sha512-G4Gr1UsGeEy2qtDTZwL7JFLo2wapUarz7iTMcYcMFdS89AIQuBoyjgXZz0Utv7uHs3xA9LckhVbeBi8lEQrC+w==",
1678 "version": "3.9.3",
1679 "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.9.3.tgz",
1680 "integrity": "sha512-fF4Q7QsyKVF5Rj0qvI8BgUNjqzC2JvQlpTaPLjVJVxYVUX5Zr9un+y3w1HmA4nNKdFmRBT8z/WmrjvXzXVerKQ==",
16801681 "license": "Apache-2.0",
16811682 "engines": {
16821683 "bare": ">=1.14.0"
16831684 }
16841685 },
16851686 "node_modules/bare-path": {
1686 "version": "3.0.1",
1687 "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.1.tgz",
1688 "integrity": "sha512-ghj2DSK/2e99a1anTVPCV4m4YIYtrbXhfM7V3D7XZLOTsybnYyaJloymGqssQc8l/or0UoDyRtNQkmkEF/ysgQ==",
1689 "license": "Apache-2.0",
1690 "dependencies": {
1691 "bare-os": "^3.0.1"
1692 }
1687 "version": "3.1.1",
1688 "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.1.1.tgz",
1689 "integrity": "sha512-JprUlveX3QjApC1cTpsUOiscADftCGVWkzitbHsRqv84hzYwYHw2mbluddsq5TvI8mH/8Ov1f4BiMAdcB0oYnQ==",
1690 "license": "Apache-2.0"
16931691 },
16941692 "node_modules/bare-pipe": {
16951693 "version": "4.2.2",
……@@ -2070,34 +2068,6 @@
20702068 "bare": "bin/bare.exe"
20712069 }
20722070 },
2073 "node_modules/bare-runtime/node_modules/bare-subprocess": {
2074 "version": "6.1.0",
2075 "resolved": "https://registry.npmjs.org/bare-subprocess/-/bare-subprocess-6.1.0.tgz",
2076 "integrity": "sha512-8L6KtbmreDy4Fc1BdDqaDo9fg0nbedUmTSFQxYPV0uHIM2BBAX8+E0LyMDKgkk0I+mlKto80WYzOnIhT8VDdOg==",
2077 "dev": true,
2078 "license": "Apache-2.0",
2079 "peer": true,
2080 "dependencies": {
2081 "bare-env": "^3.0.0",
2082 "bare-events": "^2.5.4",
2083 "bare-os": "^3.0.1",
2084 "bare-pipe": "^4.2.0",
2085 "bare-structured-clone": "^1.5.2",
2086 "bare-tcp": "^2.4.1",
2087 "bare-url": "^2.2.2"
2088 },
2089 "engines": {
2090 "bare": ">=1.7.0"
2091 },
2092 "peerDependencies": {
2093 "bare-buffer": "*"
2094 },
2095 "peerDependenciesMeta": {
2096 "bare-buffer": {
2097 "optional": true
2098 }
2099 }
2100 },
21012071 "node_modules/bare-semver": {
21022072 "version": "1.0.3",
21032073 "resolved": "https://registry.npmjs.org/bare-semver/-/bare-semver-1.0.3.tgz",
……@@ -2120,32 +2090,6 @@
21202090 "require-asset": "^1.1.0"
21212091 }
21222092 },
2123 "node_modules/bare-sidecar/node_modules/bare-subprocess": {
2124 "version": "6.1.0",
2125 "resolved": "https://registry.npmjs.org/bare-subprocess/-/bare-subprocess-6.1.0.tgz",
2126 "integrity": "sha512-8L6KtbmreDy4Fc1BdDqaDo9fg0nbedUmTSFQxYPV0uHIM2BBAX8+E0LyMDKgkk0I+mlKto80WYzOnIhT8VDdOg==",
2127 "license": "Apache-2.0",
2128 "dependencies": {
2129 "bare-env": "^3.0.0",
2130 "bare-events": "^2.5.4",
2131 "bare-os": "^3.0.1",
2132 "bare-pipe": "^4.2.0",
2133 "bare-structured-clone": "^1.5.2",
2134 "bare-tcp": "^2.4.1",
2135 "bare-url": "^2.2.2"
2136 },
2137 "engines": {
2138 "bare": ">=1.7.0"
2139 },
2140 "peerDependencies": {
2141 "bare-buffer": "*"
2142 },
2143 "peerDependenciesMeta": {
2144 "bare-buffer": {
2145 "optional": true
2146 }
2147 }
2148 },
21492093 "node_modules/bare-signals": {
21502094 "version": "4.2.0",
21512095 "resolved": "https://registry.npmjs.org/bare-signals/-/bare-signals-4.2.0.tgz",
……@@ -2245,6 +2189,32 @@
22452189 "bare-process": "^4.2.1"
22462190 }
22472191 },
2192 "node_modules/bare-subprocess": {
2193 "version": "6.1.0",
2194 "resolved": "https://registry.npmjs.org/bare-subprocess/-/bare-subprocess-6.1.0.tgz",
2195 "integrity": "sha512-8L6KtbmreDy4Fc1BdDqaDo9fg0nbedUmTSFQxYPV0uHIM2BBAX8+E0LyMDKgkk0I+mlKto80WYzOnIhT8VDdOg==",
2196 "license": "Apache-2.0",
2197 "dependencies": {
2198 "bare-env": "^3.0.0",
2199 "bare-events": "^2.5.4",
2200 "bare-os": "^3.0.1",
2201 "bare-pipe": "^4.2.0",
2202 "bare-structured-clone": "^1.5.2",
2203 "bare-tcp": "^2.4.1",
2204 "bare-url": "^2.2.2"
2205 },
2206 "engines": {
2207 "bare": ">=1.7.0"
2208 },
2209 "peerDependencies": {
2210 "bare-buffer": "*"
2211 },
2212 "peerDependenciesMeta": {
2213 "bare-buffer": {
2214 "optional": true
2215 }
2216 }
2217 },
22482218 "node_modules/bare-tcp": {
22492219 "version": "2.5.2",
22502220 "resolved": "https://registry.npmjs.org/bare-tcp/-/bare-tcp-2.5.2.tgz",
……@@ -3269,16 +3239,25 @@
32693239 }
32703240 },
32713241 "node_modules/hypercore-crypto": {
3272 "version": "3.6.1",
3273 "resolved": "https://registry.npmjs.org/hypercore-crypto/-/hypercore-crypto-3.6.1.tgz",
3274 "integrity": "sha512-ltIz2uDwy9pO/ZGTvqcjzyBkvt6O4cVm4r/nNxh0GFs/RbQtqP/i4wCvLEdmU7ptgtnw7fI67WYD1aHPuv4OVA==",
3242 "version": "3.7.0",
3243 "resolved": "https://registry.npmjs.org/hypercore-crypto/-/hypercore-crypto-3.7.0.tgz",
3244 "integrity": "sha512-SWxobptQf2V/+ZLCCDRTXqFzGfTPIkNIuDyLKXpEMfcpJ1/ec94N9aWgylHcWOHmRt14ng/KR7z0BugebWHK9Q==",
32753245 "license": "MIT",
32763246 "dependencies": {
32773247 "b4a": "^1.6.6",
3278 "compact-encoding": "^2.15.0",
3248 "compact-encoding": "^3.0.0",
32793249 "sodium-universal": "^5.0.0"
32803250 }
32813251 },
3252 "node_modules/hypercore-crypto/node_modules/compact-encoding": {
3253 "version": "3.3.0",
3254 "resolved": "https://registry.npmjs.org/compact-encoding/-/compact-encoding-3.3.0.tgz",
3255 "integrity": "sha512-e64XyzlBvTRJ3iScuU/U2w25Dglkm7fhrRbrGaHIwuTlNnzjRKMaQBCVl7mJRvZg7wI5a9wX1IVTXCuaAANNkw==",
3256 "license": "Apache-2.0",
3257 "dependencies": {
3258 "b4a": "^1.3.0"
3259 }
3260 },
32823261 "node_modules/hypercore-errors": {
32833262 "version": "1.5.0",
32843263 "resolved": "https://registry.npmjs.org/hypercore-errors/-/hypercore-errors-1.5.0.tgz",
……@@ -3607,6 +3586,21 @@
36073586 "integrity": "sha512-Yqi947Vk5Ps2YqhOV8K+RR6bseLhZLIVfovpWJH5cT7GE4Pca8/3iny/3oQ47scD7SfQd3whhxMfc+KgxTgDHA==",
36083587 "license": "MIT"
36093588 },
3589 "node_modules/localdrive": {
3590 "version": "2.2.1",
3591 "resolved": "https://registry.npmjs.org/localdrive/-/localdrive-2.2.1.tgz",
3592 "integrity": "sha512-r7AMrscOky3zIjzOrbH+90P12WmFdQaIlJ26TXaUJrM3hGQ6lIQkWhy8eQs5xDI5ukEyrXp4962EIgO9DFdm+g==",
3593 "license": "Apache-2.0",
3594 "dependencies": {
3595 "b4a": "^1.6.1",
3596 "bare-fs": "^4.0.1",
3597 "bare-path": "^3.0.0",
3598 "mirror-drive": "^1.2.0",
3599 "mutexify": "^1.4.0",
3600 "streamx": "^2.12.5",
3601 "unix-path-resolve": "^1.0.2"
3602 }
3603 },
36103604 "node_modules/lodash.camelcase": {
36113605 "version": "4.3.0",
36123606 "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
……@@ -4075,21 +4069,70 @@
40754069 "bare-path": "*"
40764070 }
40774071 },
4078 "node_modules/pear-aliases": {
4079 "version": "1.0.7",
4080 "resolved": "https://registry.npmjs.org/pear-aliases/-/pear-aliases-1.0.7.tgz",
4081 "integrity": "sha512-Z85C0naSKh3XMBKm1P1L2kYbIGE+Ph0HRSjTaQz5O99JvQq/wHylu9M2df1Ysfx7LG3UJrCCwZenrud18bZytw==",
4082 "license": "Apache-2.0",
4083 "dependencies": {
4084 "hypercore-id-encoding": "^1.3.0"
4085 }
4086 },
40874072 "node_modules/pear-errors": {
40884073 "version": "1.0.1",
40894074 "resolved": "https://registry.npmjs.org/pear-errors/-/pear-errors-1.0.1.tgz",
40904075 "integrity": "sha512-KQyhym09QpLT4H/z3uLW0bqixgIZic2Eip2Q8Z72QmIpFzPtp6QmnzOUwB3kuHB4dqKNa+FQWHWH0NSK5lJjlw==",
40914076 "license": "Apache-2.0"
40924077 },
4078 "node_modules/pear-install": {
4079 "version": "1.2.2",
4080 "resolved": "https://registry.npmjs.org/pear-install/-/pear-install-1.2.2.tgz",
4081 "integrity": "sha512-6C1ahd+ulP/Tl1vuwxCwJFKEdNrBbZmF8eLBPwxkYB1fnXutKOsa1UQo3tifFjCJuyqX0AkPMLtF+OnATG00Pw==",
4082 "license": "Apache-2.0",
4083 "dependencies": {
4084 "bare-fs": "^4.7.1",
4085 "bare-os": "^3.9.1",
4086 "bare-path": "^3.0.0",
4087 "bare-process": "^4.4.1",
4088 "bare-subprocess": "^6.0.0",
4089 "corestore": "^7.9.2",
4090 "hypercore-crypto": "^3.7.0",
4091 "hyperdrive": "^13.3.2",
4092 "hyperswarm": "^4.17.0",
4093 "localdrive": "^2.2.1",
4094 "paparam": "^1.10.1",
4095 "pear-errors": "^1.0.1",
4096 "pear-link": "^5.0.0",
4097 "pear-opstream": "^1.0.1",
4098 "pear-opwait": "^1.0.0",
4099 "streamx": "^2.23.0",
4100 "tiny-byte-size": "^1.1.0"
4101 },
4102 "bin": {
4103 "pear-install": "bin.js"
4104 }
4105 },
4106 "node_modules/pear-link": {
4107 "version": "5.0.1",
4108 "resolved": "https://registry.npmjs.org/pear-link/-/pear-link-5.0.1.tgz",
4109 "integrity": "sha512-h7BZAPTJWy3oqKOw/REEgAVB+wd4JR6kZEla32U2DoePlC75yorYtDIzbqAN4VsWzmr21AZcO/XLRDaOqORFMw==",
4110 "license": "Apache-2.0",
4111 "dependencies": {
4112 "bare-path": "^3.1.1",
4113 "hypercore-id-encoding": "^1.3.0",
4114 "pear-errors": "^1.0.1"
4115 }
4116 },
4117 "node_modules/pear-opstream": {
4118 "version": "1.2.0",
4119 "resolved": "https://registry.npmjs.org/pear-opstream/-/pear-opstream-1.2.0.tgz",
4120 "integrity": "sha512-iGyZm2TWWxTK81BWOCU4IAr9hAsldnNXe2k7HlvvGnuZQ9Uow0OESO6Xiq24ldtfZm9bZIkQ/C/b1vGXQ7hloA==",
4121 "license": "Apache-2.0",
4122 "dependencies": {
4123 "pear-link": "^5.0.0",
4124 "streamx": "^2.23.0"
4125 }
4126 },
4127 "node_modules/pear-opwait": {
4128 "version": "1.0.0",
4129 "resolved": "https://registry.npmjs.org/pear-opwait/-/pear-opwait-1.0.0.tgz",
4130 "integrity": "sha512-V8h7GWihtc5Cam4e0EDJebuwpTxndSRfVsIPAll1YZgf+QG5XrpwQeF8umvxiDBYJXwtJ9P3pVnbsM4jk7vDuA==",
4131 "license": "Apache-2.0",
4132 "dependencies": {
4133 "pear-errors": "^1.0.0"
4134 }
4135 },
40934136 "node_modules/pear-runtime": {
40944137 "version": "1.3.1",
40954138 "resolved": "https://registry.npmjs.org/pear-runtime/-/pear-runtime-1.3.1.tgz",
……@@ -4125,33 +4168,6 @@
41254168 "which-runtime": "^1.3.2"
41264169 }
41274170 },
4128 "node_modules/pear-runtime-updater/node_modules/localdrive": {
4129 "version": "2.2.1",
4130 "resolved": "https://registry.npmjs.org/localdrive/-/localdrive-2.2.1.tgz",
4131 "integrity": "sha512-r7AMrscOky3zIjzOrbH+90P12WmFdQaIlJ26TXaUJrM3hGQ6lIQkWhy8eQs5xDI5ukEyrXp4962EIgO9DFdm+g==",
4132 "license": "Apache-2.0",
4133 "dependencies": {
4134 "b4a": "^1.6.1",
4135 "bare-fs": "^4.0.1",
4136 "bare-path": "^3.0.0",
4137 "mirror-drive": "^1.2.0",
4138 "mutexify": "^1.4.0",
4139 "streamx": "^2.12.5",
4140 "unix-path-resolve": "^1.0.2"
4141 }
4142 },
4143 "node_modules/pear-runtime-updater/node_modules/pear-link": {
4144 "version": "5.0.0",
4145 "resolved": "https://registry.npmjs.org/pear-link/-/pear-link-5.0.0.tgz",
4146 "integrity": "sha512-Va2xz1vdm4akNonOXOQLsvsxIcT/SWai2R03l9JA5i7YTZa9F3y9AsxtUNBRNLkC4sA6Bk6+9XIa2FzvuoK/FA==",
4147 "license": "Apache-2.0",
4148 "dependencies": {
4149 "bare-path": "^3.0.0",
4150 "hypercore-id-encoding": "^1.2.0",
4151 "pear-aliases": "^1.0.0",
4152 "pear-errors": "^1.0.0"
4153 }
4154 },
41554171 "node_modules/pino": {
41564172 "version": "9.14.0",
41574173 "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz",
package.json +2 -1
……@@ -103,6 +103,7 @@
103103 "bip39-mnemonic": "^2.5.0",
104104 "blind-pairing": "^2.3.1",
105105 "corestore": "^7.9.2",
106 "electron": "43.2.0",
106107 "framed-stream": "^1.0.0",
107108 "htm": "^3.1.1",
108109 "hyperbee": "^2.27.3",
……@@ -113,7 +114,7 @@
113114 "p2p-hiverelay": "latest",
114115 "p2p-hiverelay-client": "latest",
115116 "p2p-hiverelay-verifier": "latest",
116 "electron": "43.2.0",
117 "pear-install": "1.2.2",
117118 "pear-runtime": "1.3.1",
118119 "react": "^18.3.1",
119120 "react-dom": "^18.3.1",
test/catalog-manager-safety.test.js +41 -0
……@@ -96,6 +96,47 @@ test('catalog app normalizer drops unsafe and executable targets', () => {
9696 assert.equal(hyper.link, `hyper://${driveKey}/app`)
9797})
9898
99test('catalog app normalizer accepts explicit Pear v3 native delivery without treating it as tab content', () => {
100 const installLink = `pear://${'a'.repeat(52)}`
101 const app = normalizeCatalogApp({
102 id: 'native-demo',
103 name: 'Native Demo',
104 verification: 'author-signed',
105 nativeDelivery: {
106 status: 'available',
107 kind: 'pear-v3',
108 installLink: installLink.toUpperCase(),
109 productName: 'Native Demo',
110 targets: ['darwin-arm64', 'linux-x64', '../bad']
111 }
112 })
113
114 assert.equal(app.link, undefined)
115 assert.deepEqual(app.nativeDelivery, {
116 status: 'available',
117 kind: 'pear-v3',
118 installLink,
119 productName: 'Native Demo',
120 targets: ['darwin-arm64', 'linux-x64']
121 })
122 assert.equal(catalogAppStableKey(app), `native:${installLink}`)
123 assert.ok(catalogAppSearchText(app).includes('linux-x64'))
124
125 const migrated = normalizeCatalogApp({
126 id: 'native-with-legacy-link',
127 name: 'Migrated Native Demo',
128 link: `pear://${'b'.repeat(52)}`,
129 nativeDelivery: app.nativeDelivery
130 })
131 assert.equal(migrated.link, undefined)
132 assert.equal(migrated.nativeDelivery.installLink, installLink)
133
134 assert.equal(normalizeCatalogApp({
135 name: 'Missing v3 metadata',
136 nativeDelivery: { status: 'available', kind: 'pear-v3', installLink: 'pear://keet' }
137 }), null)
138})
139
99140test('shared catalog app normalizer builds stable target keys', () => {
100141 const driveKey = key('d')
101142 const app = normalizeCatalogApp({
test/pear-v3-app-launcher.test.js +208 -0
……@@ -0,0 +1,208 @@
1import test from 'node:test'
2import assert from 'node:assert/strict'
3import { EventEmitter } from 'node:events'
4import launcherMod from '../electron/pear-app-launcher.cjs'
5
6const { PearAppLauncher, normalizePearInstallLink, validateInstallEvent, validateResolvedTargets } = launcherMod
7const KEY = 'a'.repeat(52)
8const LINK = `pear://${KEY}`
9
10function installEvent (overrides = {}) {
11 return {
12 app: 'Demo App',
13 name: 'demo-app',
14 version: '3.0.0',
15 upgrade: LINK,
16 verlink: `pear://0.42.${KEY}`,
17 key: '/by-arch/linux-x64/app/Demo App.AppImage',
18 dest: '/home/test/.local/bin/Demo App.AppImage',
19 ...overrides
20 }
21}
22
23test('Pear v3 install links accept only canonical root keys', () => {
24 assert.equal(normalizePearInstallLink(`${LINK}/`), LINK)
25 assert.equal(normalizePearInstallLink(`PEAR://${KEY.toUpperCase()}`), LINK)
26 assert.throws(() => normalizePearInstallLink(`pear://${KEY}/worker.js`), /must not contain a path/)
27 assert.throws(() => normalizePearInstallLink('pear://keet'), /52-character Pear key/)
28 assert.throws(() => normalizePearInstallLink(`hyper://${KEY}`), /canonical 52-character Pear key/)
29})
30
31test('installer metadata is constrained to one GUI artifact and default OS destination', () => {
32 assert.deepEqual(validateInstallEvent(installEvent(), {
33 platform: 'linux',
34 arch: 'x64',
35 homeDir: '/home/test',
36 expectedLink: LINK
37 }), {
38 app: 'Demo App',
39 packageName: 'demo-app',
40 version: '3.0.0',
41 upgrade: LINK,
42 verlink: `pear://0.42.${KEY}`,
43 key: '/by-arch/linux-x64/app/Demo App.AppImage',
44 dest: '/home/test/.local/bin/Demo App.AppImage'
45 })
46 assert.equal(validateInstallEvent(installEvent({ verlink: `pear://0.42.${KEY}.${'b'.repeat(52)}` }), {
47 platform: 'linux', arch: 'x64', homeDir: '/home/test', expectedLink: LINK
48 }).verlink, `pear://0.42.${KEY}.${'b'.repeat(52)}`)
49
50 assert.throws(() => validateInstallEvent(installEvent({
51 app: 'demo-cli',
52 key: '/by-arch/linux-x64/app/demo-cli',
53 dest: '/home/test/.local/bin/demo-cli'
54 }), { platform: 'linux', homeDir: '/home/test', expectedLink: LINK }), /non-GUI executable target/)
55 assert.throws(() => validateInstallEvent(installEvent({ key: '/by-arch/linux-arm64/app/Demo App.AppImage' }), {
56 platform: 'linux', arch: 'x64', homeDir: '/home/test', expectedLink: LINK
57 }), /non-GUI executable target/)
58 assert.throws(() => validateInstallEvent(installEvent({ dest: '/tmp/Demo App.AppImage' }), {
59 platform: 'linux', homeDir: '/home/test', expectedLink: LINK
60 }), /unexpected Linux install destination/)
61 assert.throws(() => validateInstallEvent(installEvent({ upgrade: `pear://${'b'.repeat(52)}` }), {
62 platform: 'linux', homeDir: '/home/test', expectedLink: LINK
63 }), /upgrade identity does not match/)
64 assert.throws(() => validateInstallEvent(installEvent({ verlink: `${LINK}/worker.js` }), {
65 platform: 'linux', homeDir: '/home/test', expectedLink: LINK
66 }), /invalid version link/)
67 assert.throws(() => validateInstallEvent(installEvent(), {
68 platform: 'linux', homeDir: '/home/test', expectedLink: LINK, expectedAppName: 'Other App'
69 }), /product name does not match/)
70})
71
72test('resolved installer targets reject mixed GUI and command-line packages before installation', () => {
73 const app = {
74 filename: 'Demo App',
75 ext: '.AppImage',
76 dest: '/home/test/.local/bin/Demo App.AppImage',
77 isBin: false
78 }
79 assert.deepEqual(validateResolvedTargets([app], { platform: 'linux', homeDir: '/home/test' }), {
80 app: 'Demo App',
81 ext: '.AppImage',
82 dest: '/home/test/.local/bin/Demo App.AppImage'
83 })
84 assert.throws(() => validateResolvedTargets([
85 { filename: 'demo-cli', ext: '', dest: '/home/test/.local/bin/demo-cli', isBin: true },
86 app
87 ], { platform: 'linux', homeDir: '/home/test' }), /multiple installable targets/)
88})
89
90test('native installer records authoritative package metadata and closes its resources', async () => {
91 let instance = null
92 class FakeInstall extends EventEmitter {
93 constructor (opts) {
94 super()
95 this.opts = opts
96 this.closed = false
97 instance = this
98 }
99
100 async ready () {
101 this.emit('installing', { link: this.opts.link, host: 'linux-x64' })
102 this.emit('stats', { peers: 2, download: { bytes: 1024, speed: 256 } })
103 this.emit('app', installEvent())
104 this.emit('final', { success: true, installed: 1, exists: [] })
105 }
106
107 async close () { this.closed = true }
108 }
109
110 const progress = []
111 const launcher = new PearAppLauncher({
112 platform: 'linux',
113 arch: 'x64',
114 homeDir: '/home/test',
115 Install: FakeInstall
116 })
117 const record = await launcher.install({ id: 'demo', name: 'Demo', link: LINK, productName: 'Demo App', verification: 'author-signed' }, (event) => progress.push(event))
118
119 assert.equal(instance.opts.link, LINK)
120 assert.deepEqual(Object.keys(instance.opts), ['link'])
121 assert.equal(instance.closed, true)
122 assert.equal(record.app, 'Demo App')
123 assert.equal(record.packageName, 'demo-app')
124 assert.equal(record.dest, '/home/test/.local/bin/Demo App.AppImage')
125 assert.equal(record.verification, 'author-signed')
126 assert.deepEqual(progress.map((event) => event.phase), ['connecting', 'downloading', 'installing', 'complete'])
127})
128
129test('native installer rejects packages that expose a binary target', async () => {
130 let closed = false
131 class FakeInstall extends EventEmitter {
132 async ready () {
133 this.emit('app', installEvent({
134 app: 'demo-cli',
135 key: '/by-arch/linux-x64/app/demo-cli',
136 dest: '/home/test/.local/bin/demo-cli'
137 }))
138 }
139
140 async close () { closed = true }
141 }
142
143 const launcher = new PearAppLauncher({ platform: 'linux', arch: 'x64', homeDir: '/home/test', Install: FakeInstall })
144 await assert.rejects(launcher.install({ id: 'demo', link: LINK }), /non-GUI executable target/)
145 assert.equal(closed, true)
146})
147
148test('native Linux launch uses only the recorded installed artifact', async () => {
149 let spawned = null
150 const launcher = new PearAppLauncher({
151 platform: 'linux',
152 arch: 'x64',
153 homeDir: '/home/test',
154 fs: { existsSync: (filename) => filename === '/home/test/.local/bin/Demo App.AppImage' },
155 spawn: (filename, args, opts) => {
156 spawned = { filename, args, opts, unref: false }
157 return { unref: () => { spawned.unref = true } }
158 }
159 })
160 launcher.records.set(LINK, {
161 id: 'demo',
162 link: LINK,
163 app: 'Demo App',
164 packageName: 'demo-app',
165 version: '3.0.0',
166 dest: '/home/test/.local/bin/Demo App.AppImage',
167 platform: 'linux',
168 arch: 'x64'
169 })
170
171 const result = await launcher.launch(LINK)
172 assert.equal(result.launched, true)
173 assert.deepEqual(spawned, {
174 filename: '/home/test/.local/bin/Demo App.AppImage',
175 args: [],
176 opts: { detached: true, stdio: 'ignore' },
177 unref: true
178 })
179})
180
181test('persisted native app records are revalidated before they can be launched', () => {
182 const valid = {
183 id: 'demo',
184 link: LINK,
185 app: 'Demo App',
186 packageName: 'demo-app',
187 version: '3.0.0',
188 upgrade: LINK,
189 verlink: `pear://0.42.${KEY}`,
190 artifactKey: '/by-arch/linux-x64/app/Demo App.AppImage',
191 dest: '/home/test/.local/bin/Demo App.AppImage',
192 platform: 'linux',
193 arch: 'x64'
194 }
195 const launcher = new PearAppLauncher({
196 platform: 'linux',
197 arch: 'x64',
198 homeDir: '/home/test',
199 dataFile: '/state/pear-v3-apps.json',
200 fs: {
201 readFileSync: () => JSON.stringify({ apps: [valid, { ...valid, link: `pear://${'b'.repeat(52)}`, dest: '/tmp/evil.AppImage' }] }),
202 existsSync: () => true
203 }
204 })
205
206 assert.equal(launcher.list().length, 1)
207 assert.equal(launcher.list()[0].link, LINK)
208})
test/pear-v3-host-contract.test.js +11 -0
……@@ -6,6 +6,7 @@ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url),
66const worker = readFileSync(new URL('../workers/main.js', import.meta.url), 'utf8')
77const host = readFileSync(new URL('../electron/main.cjs', import.meta.url), 'utf8')
88const preload = readFileSync(new URL('../electron/preload.cjs', import.meta.url), 'utf8')
9const appLauncher = readFileSync(new URL('../electron/pear-app-launcher.cjs', import.meta.url), 'utf8')
910const backendEntry = readFileSync(new URL('../index.js', import.meta.url), 'utf8')
1011const boot = readFileSync(new URL('../ui/boot.js', import.meta.url), 'utf8')
1112
……@@ -13,6 +14,7 @@ test('PearBrowser v3 uses an embedded Pear OTA worker behind a native Electron h
1314 assert.equal(pkg.main, 'electron/main.cjs')
1415 assert.equal(pkg.scripts.start, 'electron electron/main.cjs')
1516 assert.equal(pkg.dependencies?.['pear-runtime'], '1.3.1')
17 assert.equal(pkg.dependencies?.['pear-install'], '1.2.2')
1618 assert.equal(pkg.dependencies?.electron, '43.2.0')
1719 assert.match(pkg.upgrade, /^pear:\/\/[13-9a-km-uw-z]{52}$/)
1820
……@@ -24,6 +26,15 @@ test('PearBrowser v3 uses an embedded Pear OTA worker behind a native Electron h
2426 assert.match(host, /contextIsolation: true/)
2527 assert.match(host, /nodeIntegration: false/)
2628 assert.match(preload, /contextBridge\.exposeInMainWorld\('pearbrowserRuntime'/)
29 assert.match(preload, /installPearApp/)
30 assert.match(preload, /launchPearApp/)
31 assert.match(host, /pearbrowser:pear-apps:install/)
32 assert.match(host, /pearbrowser:pear-apps:launch/)
33 assert.match(host, /process\.platform.*process\.arch/)
34 assert.match(host, /does not independently verify catalogue authorship/)
35 assert.match(appLauncher, /new Install\(\{ link \}\)/)
36 assert.match(appLauncher, /non-GUI executable target/)
37 assert.match(appLauncher, /_partitionTargets/)
2738 assert.match(worker, /Bare\.argv\[2\]/)
2839 assert.match(worker, /PearBrowserRuntime = \{ storagePath, sessionToken \}/)
2940 assert.match(backendEntry, /globalThis\.PearBrowserRuntime/)
test/sheets-catalog-query.test.js +28 -1
……@@ -6,7 +6,34 @@ import test from 'node:test'
66import assert from 'node:assert/strict'
77import sheetsCatalog from '../backend/sheets-catalog.js'
88
9const { SheetsCatalog, safeJmesPath, MAX_QUERY_LEN, MAX_SHEETS_ROWS } = sheetsCatalog
9const { SheetsCatalog, APPS_SCHEMA, rowToApp, safeJmesPath, MAX_QUERY_LEN, MAX_SHEETS_ROWS } = sheetsCatalog
10
11test('sheets catalogue maps Pear v3 native delivery columns into the shared app contract', () => {
12 const key = 'a'.repeat(52)
13 assert.deepEqual(APPS_SCHEMA.anyOf.at(-1), {
14 properties: { nativeDeliveryStatus: { const: 'available' } },
15 required: ['nativeDeliveryStatus', 'nativeDeliveryKind', 'nativeInstallLink']
16 })
17 const app = rowToApp({
18 uuid: 'native-demo',
19 json: {
20 name: 'Native Demo',
21 type: 'standalone',
22 nativeDeliveryStatus: 'available',
23 nativeDeliveryKind: 'pear-v3',
24 nativeInstallLink: `pear://${key}`,
25 nativeProductName: 'Native Demo',
26 nativeTargets: ['linux-x64']
27 }
28 })
29 assert.deepEqual(app.nativeDelivery, {
30 status: 'available',
31 kind: 'pear-v3',
32 installLink: `pear://${key}`,
33 productName: 'Native Demo',
34 targets: ['linux-x64']
35 })
36})
1037
1138test('safeJmesPath treats empty/absent query as "no filter"', () => {
1239 assert.equal(safeJmesPath(undefined), undefined)
ui/shell.js +143 -18
……@@ -2691,6 +2691,8 @@ function appStableDedupeKey (app) {
26912691 const hyperKey = /^hyper:\/\//i.test(link) ? driveKeyFromHyperRef(link) : ''
26922692 if (driveKey || hyperKey) return 'drive:' + (driveKey || hyperKey)
26932693 if (/^hyper:\/\/.+/i.test(link)) return 'link:' + link
2694 const nativeInstallLink = String(app.nativeDelivery?.installLink || '').trim().toLowerCase().replace(/\/$/, '')
2695 if (app.nativeDelivery?.status === 'available' && app.nativeDelivery?.kind === 'pear-v3' && /^pear:\/\/[13-9a-km-uw-z]{52}$/.test(nativeInstallLink)) return 'native:' + nativeInstallLink
26942696 const legacyMigrationId = String(app.legacyMigrationId || '').trim().toLowerCase()
26952697 if (/^[13-9a-km-uw-z]{52}$/.test(legacyMigrationId)) return 'legacy:' + legacyMigrationId
26962698 const id = String(app.id || '').trim()
……@@ -2794,6 +2796,8 @@ function Apps ({ rpc, C, onLaunch }) {
27942796 // via user-data settings so they survive across launches.
27952797 const [recentCatalogs, setRecentCatalogs] = useState([])
27962798 const [installed, setInstalled] = useState([])
2799 const [nativeApps, setNativeApps] = useState([])
2800 const [nativeProgress, setNativeProgress] = useState(null)
27972801 const [busy, setBusy] = useState(null)
27982802 const [err, setErr] = useState('')
27992803 const [autoLoadAttempted, setAutoLoadAttempted] = useState(false)
……@@ -2885,6 +2889,78 @@ function Apps ({ rpc, C, onLaunch }) {
28852889 }
28862890 }
28872891
2892 const refreshNativeApps = async () => {
2893 try {
2894 const host = globalThis.pearbrowserRuntime
2895 if (!host || typeof host.listPearApps !== 'function') return setNativeApps([])
2896 const list = await host.listPearApps()
2897 setNativeApps(Array.isArray(list) ? list : [])
2898 } catch (e) {
2899 setErr(`native apps: ${e.message}`)
2900 }
2901 }
2902
2903 const nativeInstallLink = (app) => app?.nativeDelivery?.status === 'available' && app?.nativeDelivery?.kind === 'pear-v3'
2904 ? String(app.nativeDelivery.installLink || '')
2905 : ''
2906
2907 const nativeRecordFor = (app) => {
2908 const link = nativeInstallLink(app)
2909 if (!link) return null
2910 return nativeApps.find((record) => record.link === link) || null
2911 }
2912
2913 const installNativeApp = async (app) => {
2914 const host = globalThis.pearbrowserRuntime
2915 if (!host || typeof host.installPearApp !== 'function') {
2916 setErr('Native Pear v3 installation is unavailable in this build.')
2917 return
2918 }
2919 const link = nativeInstallLink(app)
2920 if (!link) {
2921 setErr(`${app?.name || 'This app'} has no valid Pear v3 install link.`)
2922 return
2923 }
2924 setErr(''); setLaunched(''); setNativeProgress(null); setBusy(`native-install:${link}`)
2925 try {
2926 const result = await host.installPearApp({
2927 id: app.id,
2928 name: app.name,
2929 verification: app.verification,
2930 nativeDelivery: app.nativeDelivery
2931 })
2932 if (result?.cancelled) return
2933 await refreshNativeApps()
2934 setLaunched(result?.exists ? `${result.app || app.name} is already installed.` : `Installed ${result?.app || app.name} as a native Pear v3 app.`)
2935 setTimeout(() => setLaunched(''), 5000)
2936 } catch (e) {
2937 setErr(`install ${app.name}: ${e.message}`)
2938 } finally {
2939 setBusy(null)
2940 setNativeProgress(null)
2941 }
2942 }
2943
2944 const launchNativeApp = async (app) => {
2945 const host = globalThis.pearbrowserRuntime
2946 if (!host || typeof host.launchPearApp !== 'function') {
2947 setErr('Native Pear v3 launching is unavailable in this build.')
2948 return
2949 }
2950 const target = nativeInstallLink(app) || app?.link || app?.id
2951 setErr(''); setLaunched(''); setBusy(`native-launch:${target}`)
2952 try {
2953 const result = await host.launchPearApp({ link: target, id: app?.id })
2954 setLaunched(`Opened ${result?.app || app?.name || 'Pear app'} in its native window.`)
2955 setTimeout(() => setLaunched(''), 4000)
2956 await refreshNativeApps()
2957 } catch (e) {
2958 setErr(`launch ${app?.name || 'app'}: ${e.message}`)
2959 } finally {
2960 setBusy(null)
2961 }
2962 }
2963
28882964 // Ask the backend which installed apps are behind the loaded catalog's
28892965 // version. Non-critical — a failure just means no update badges.
28902966 const refreshUpdates = async () => {
……@@ -3141,6 +3217,7 @@ function Apps ({ rpc, C, onLaunch }) {
31413217 // the aggregated store is populated, not just the most recent one.
31423218 useEffect(() => {
31433219 refreshInstalled()
3220 refreshNativeApps()
31443221 // Pull the aggregated store immediately so backend-registered catalogues
31453222 // (e.g. the default schema-sheets catalogue, seeded on boot) show up without
31463223 // waiting on a recent/relay catalog load to resolve.
……@@ -3223,6 +3300,12 @@ function Apps ({ rpc, C, onLaunch }) {
32233300 // eslint-disable-next-line react-hooks/exhaustive-deps
32243301 }, [])
32253302
3303 useEffect(() => {
3304 const host = globalThis.pearbrowserRuntime
3305 if (!host || typeof host.onPearAppProgress !== 'function') return
3306 return host.onPearAppProgress((progress) => setNativeProgress(progress || null))
3307 }, [])
3308
32263309 const installApp = async (app) => {
32273310 setErr(''); setBusy(`install:${app.id}`)
32283311 try {
……@@ -3273,7 +3356,7 @@ function Apps ({ rpc, C, onLaunch }) {
32733356 const q = query.normalize('NFKC').trim().toLowerCase()
32743357 const matched = apps.filter((a) => {
32753358 // Apps page presents browsable content and legacy migration records.
3276 if (!a || (!a.link && !a.legacyMigrationId)) return false
3359 if (!a || (!a.link && !a.legacyMigrationId && !nativeInstallLink(a))) return false
32773360 if (source !== 'all' && a.catalogKey !== source) return false
32783361 if (category !== 'all' && !appCategories(a).includes(category)) return false
32793362 if (!q) return true
……@@ -3284,7 +3367,7 @@ function Apps ({ rpc, C, onLaunch }) {
32843367 }, [apps, query, category, source])
32853368
32863369 // Total unique-app count (deduped, ignoring search/category) for the headers.
3287 const uniqueAppCount = useMemo(() => dedupeApps(apps.filter((a) => a && (a.link || a.legacyMigrationId))).length, [apps])
3370 const uniqueAppCount = useMemo(() => dedupeApps(apps.filter((a) => a && (a.link || a.legacyMigrationId || nativeInstallLink(a)))).length, [apps])
32883371
32893372 const renderMyCatalogApp = (app) => {
32903373 const savedId = app.id || app.driveKey || app.name || 'untitled'
……@@ -3395,6 +3478,15 @@ function Apps ({ rpc, C, onLaunch }) {
33953478 <p className="placeholder">Older remote app links cannot run in PearBrowser. Install only a publisher-provided, verified native v3 package.</p>
33963479 </div>
33973480 ${launched && html`<div className="apps-ok">${launched}</div>`}
3481 ${nativeProgress && html`<div className="apps-ok">
3482 ${nativeProgress.phase === 'downloading'
3483 ? `Downloading native app · ${formatBytes(nativeProgress.download?.bytes || 0)} · ${nativeProgress.peers || 0} peer${nativeProgress.peers === 1 ? '' : 's'}`
3484 : nativeProgress.phase === 'connecting'
3485 ? 'Finding Pear v3 release peers…'
3486 : nativeProgress.phase === 'installing'
3487 ? `Installing ${nativeProgress.app || 'native app'}${nativeProgress.version ? ` v${nativeProgress.version}` : ''}…`
3488 : 'Preparing native app…'}
3489 </div>`}
33983490
33993491 <h2>App Catalog</h2>
34003492 <div className="catalog-loader">
……@@ -3490,20 +3582,28 @@ function Apps ({ rpc, C, onLaunch }) {
34903582 ${app.version ? 'v' + app.version : ''} ${app.author ? '· ' + app.author : ''}
34913583 ${app.nativeDelivery?.status === 'migration-required'
34923584 ? html`<span style=${{ marginLeft: '6px', opacity: 0.75 }}>· verified native package required</span>`
3493 : (app.type === 'hypersite' ? html`<span style=${{ marginLeft: '6px', opacity: 0.75 }}>· opens in a tab</span>` : '')}
3585 : (nativeInstallLink(app)
3586 ? html`<span style=${{ marginLeft: '6px', opacity: 0.75 }}>· Pear v3 native app</span>`
3587 : (app.type === 'hypersite' ? html`<span style=${{ marginLeft: '6px', opacity: 0.75 }}>· opens in a tab</span>` : ''))}
34943588 </div>
34953589 ${app.catalogName && html`<div className="app-source-tag">${app.catalogName}</div>`}
34963590 <${AppMeta} rpc=${rpc} C=${C} app=${app} />
34973591 </div>
34983592 <div className="app-actions">
34993593 ${(() => {
3594 const installLink = nativeInstallLink(app)
3595 const nativeRecord = nativeRecordFor(app)
35003596 return html`
35013597 ${app.driveKey && /^[0-9a-f]{64}$/i.test(app.driveKey)
35023598 ? html`<button key="open-page" className="btn subtle" onClick=${() => openSite(app)} title="Open this app's P2P page in a tab">Open page</button>`
35033599 : ''}
3504 ${app.nativeDelivery?.status === 'migration-required'
3505 ? html`<button key="migration" className="btn primary" onClick=${() => showLegacyMigration(app)} disabled=${busy === 'legacy-migration'} title="Requires a verified native v3 package">Migration status</button>`
3506 : html`<button key="open-content" className="btn primary" onClick=${() => openSite(app)} title="Open browsable P2P content">Open</button>`}
3600 ${installLink
3601 ? (nativeRecord?.installed
3602 ? html`<button key="open-native" className="btn primary" onClick=${() => launchNativeApp(app)} disabled=${busy === `native-launch:${installLink}`} title="Open the installed native application">Open app</button>`
3603 : html`<button key="install-native" className="btn primary" onClick=${() => installNativeApp(app)} disabled=${busy === `native-install:${installLink}`} title="Install the Pear v3 build into your operating system">${busy === `native-install:${installLink}` ? 'Installing…' : 'Install app'}</button>`)
3604 : (app.nativeDelivery?.status === 'migration-required'
3605 ? html`<button key="migration" className="btn primary" onClick=${() => showLegacyMigration(app)} disabled=${busy === 'legacy-migration'} title="Requires a verified native v3 package">Migration status</button>`
3606 : html`<button key="open-content" className="btn primary" onClick=${() => openSite(app)} title="Open browsable P2P content">Open</button>`)}
35073607 ${canEditMyCatalog && app.catalogKey !== myCatalog.keyHex && !inMyCatalog([app.id, app.driveKey, app.link]) && html`
35083608 <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>
35093609 `}
……@@ -3582,9 +3682,27 @@ function Apps ({ rpc, C, onLaunch }) {
35823682 </div>
35833683 `}
35843684
3585 <h2>Installed</h2>
3685 <h2>Native Pear apps</h2>
3686 ${nativeApps.length === 0
3687 ? html`<p className="placeholder">No native Pear v3 apps installed through PearBrowser yet.</p>`
3688 : html`<div className="app-grid">
3689 ${nativeApps.map((app) => html`
3690 <div className="app-card" key=${app.link}>
3691 <div className="app-icon app-icon-fallback">${(app.app || app.displayName || '?').charAt(0)}</div>
3692 <div className="app-info">
3693 <div className="app-name">${app.app || app.displayName}</div>
3694 <div className="app-meta">v${app.version || '?'} · native ${app.platform || ''}${app.installed ? '' : ' · not found at recorded OS location'}</div>
3695 </div>
3696 <div className="app-actions">
3697 <button key="launch-native-installed" className="btn primary" onClick=${() => launchNativeApp({ id: app.id, name: app.app, nativeDelivery: { status: 'available', kind: 'pear-v3', installLink: app.link } })} disabled=${!app.installed || busy === `native-launch:${app.link}`}>Open app</button>
3698 </div>
3699 </div>
3700 `)}
3701 </div>`}
3702
3703 <h2>Installed sites</h2>
35863704 ${installed.length === 0
3587 ? html`<p className="placeholder">No apps installed yet.</p>`
3705 ? html`<p className="placeholder">No Hyperdrive sites installed yet.</p>`
35883706 : html`<div className="app-grid">
35893707 ${installed.map((app) => html`
35903708 <div className="app-card" key=${app.id}>
……@@ -3640,13 +3758,14 @@ function Apps ({ rpc, C, onLaunch }) {
36403758 ${detailApp.categories.map((c) => html`<span key=${c} style=${{ fontSize: '12px', padding: '2px 9px', borderRadius: '8px', background: 'rgba(255,255,255,0.06)', color: '#8b949e' }}>${c}</span>`)}
36413759 </div>` : ''}
36423760 <div style=${{ fontSize: '13px', color: '#8b949e', display: 'grid', gap: '6px', marginBottom: '18px' }}>
3643 <div><strong style=${{ color: '#c9d1d9' }}>Runs:</strong> ${detailApp.type === 'hypersite' ? 'headless in a tab' : 'in its own window'}</div>
3761 <div><strong style=${{ color: '#c9d1d9' }}>Runs:</strong> ${nativeInstallLink(detailApp) ? 'as a native Pear v3 OS application' : (detailApp.type === 'hypersite' ? 'headless in a tab' : 'in its own window')}</div>
36443762 ${detailApp.version ? html`<div><strong style=${{ color: '#c9d1d9' }}>Version:</strong> v${detailApp.version}</div>` : ''}
36453763 <div><strong style=${{ color: '#c9d1d9' }}>Verification:</strong> ${detailApp.verification || 'unverified'}</div>
36463764 ${detailApp.homepage ? html`<div style=${{ wordBreak: 'break-all' }}><strong style=${{ color: '#c9d1d9' }}>Homepage:</strong> ${detailApp.homepage}</div>` : ''}
36473765 ${detailApp.sourceUrl ? html`<div style=${{ wordBreak: 'break-all' }}><strong style=${{ color: '#c9d1d9' }}>Source:</strong> ${detailApp.sourceUrl}</div>` : ''}
36483766 ${detailApp.license ? html`<div><strong style=${{ color: '#c9d1d9' }}>License:</strong> ${detailApp.license}</div>` : ''}
36493767 ${detailApp.link ? html`<div style=${{ wordBreak: 'break-all' }}><strong style=${{ color: '#c9d1d9' }}>Link:</strong> ${detailApp.link}</div>` : ''}
3768 ${nativeInstallLink(detailApp) ? html`<div style=${{ wordBreak: 'break-all' }}><strong style=${{ color: '#c9d1d9' }}>Install:</strong> ${nativeInstallLink(detailApp)}</div>` : ''}
36503769 ${detailApp.driveKey ? html`<div style=${{ wordBreak: 'break-all' }}><strong style=${{ color: '#c9d1d9' }}>Drive:</strong> ${detailApp.driveKey}</div>` : ''}
36513770 ${(detailApp._sources && detailApp._sources.length)
36523771 ? html`<div><strong style=${{ color: '#c9d1d9' }}>Catalogue${detailApp._sources.length > 1 ? 's' : ''}:</strong> ${detailApp._sources.join(', ')}</div>`
……@@ -3654,15 +3773,21 @@ function Apps ({ rpc, C, onLaunch }) {
36543773 ${detailApp.publisherKey ? html`<div style=${{ wordBreak: 'break-all' }}><strong style=${{ color: '#c9d1d9' }}>Publisher:</strong> ${shortKey(detailApp.publisherKey)}</div>` : ''}
36553774 </div>
36563775 <div style=${{ display: 'flex', gap: '8px' }}>
3657 ${detailApp.type === 'hypersite'
3658 ? (detailApp.driveKey && !detailApp.link
3659 ? html`<button key="detail-open-site" className="btn primary" onClick=${() => { openSite(detailApp); setDetailApp(null) }}>Open</button>`
3660 : html`<button key="detail-run-tab" className="btn primary" onClick=${() => { runInTab(detailApp); setDetailApp(null) }}>Run in tab</button>`)
3661 : (detailApp.link && !detailApp.driveKey)
3662 ? html`<button key="detail-open-window" className="btn primary" onClick=${() => { launchFeaturedApp(detailApp); setDetailApp(null) }}>Open</button>`
3663 : (isInstalled(detailApp.id)
3664 ? html`<button key="detail-launch" className="btn primary" onClick=${() => { launchApp(detailApp); setDetailApp(null) }}>Launch</button>`
3665 : html`<button key="detail-install" className="btn primary" onClick=${() => { installApp(detailApp); setDetailApp(null) }}>Install</button>`)}
3776 ${nativeInstallLink(detailApp)
3777 ? (nativeRecordFor(detailApp)?.installed
3778 ? html`<button key="detail-open-native" className="btn primary" onClick=${() => { launchNativeApp(detailApp); setDetailApp(null) }}>Open app</button>`
3779 : html`<button key="detail-install-native" className="btn primary" onClick=${() => { installNativeApp(detailApp); setDetailApp(null) }}>Install app</button>`)
3780 : detailApp.nativeDelivery?.status === 'migration-required'
3781 ? html`<button key="detail-migration" className="btn primary" onClick=${() => { showLegacyMigration(detailApp); setDetailApp(null) }}>Migration status</button>`
3782 : detailApp.type === 'hypersite'
3783 ? (detailApp.driveKey && !detailApp.link
3784 ? html`<button key="detail-open-site" className="btn primary" onClick=${() => { openSite(detailApp); setDetailApp(null) }}>Open</button>`
3785 : html`<button key="detail-run-tab" className="btn primary" onClick=${() => { runInTab(detailApp); setDetailApp(null) }}>Run in tab</button>`)
3786 : (detailApp.link && !detailApp.driveKey)
3787 ? html`<button key="detail-open-window" className="btn primary" onClick=${() => { launchFeaturedApp(detailApp); setDetailApp(null) }}>Open</button>`
3788 : (isInstalled(detailApp.id)
3789 ? html`<button key="detail-launch" className="btn primary" onClick=${() => { launchApp(detailApp); setDetailApp(null) }}>Launch</button>`
3790 : html`<button key="detail-install" className="btn primary" onClick=${() => { installApp(detailApp); setDetailApp(null) }}>Install</button>`)}
36663791 <button key="detail-close" className="btn" onClick=${() => setDetailApp(null)}>Close</button>
36673792 </div>
36683793 </div>