| … | … | @@ -52,8 +52,9 @@ your platform, and use the migration guidance in |
| 52 | 52 | |
| 53 | 53 | ### Apps |
| 54 | 54 | - 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. | |
| 57 | 58 | - 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 |
| 58 | 59 | - Keep multiple catalogs loaded at once with search, category, and source filters across the aggregated app store |
| 59 | 60 | - **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 |
| 163 | 164 | |
| 164 | 165 | Source 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. |
| 165 | 166 | |
| 166 | UI 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. | |
| 167 | UI 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. | |
| 167 | 168 | |
| 168 | 169 | ## Release pipeline |
| 169 | 170 |
| … | … | @@ -625,8 +625,9 @@ class CatalogManager { |
| 625 | 625 | const drive = await this._ensureMyCatalogDrive(keyHex) |
| 626 | 626 | if (!drive.writable) throw new Error('This catalog is not editable on this device.') |
| 627 | 627 | 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 | |
| 630 | 631 | |
| 631 | 632 | const data = await this._readMyCatalogData(drive) |
| 632 | 633 | const idx = data.apps.findIndex((a) => a && a.id === entry.id) |
| … | … | @@ -664,7 +665,8 @@ class CatalogManager { |
| 664 | 665 | const existing = data.apps[idx] |
| 665 | 666 | const stableId = existing.id || appId |
| 666 | 667 | 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.') | |
| 668 | 670 | entry.id = stableId |
| 669 | 671 | data.apps[idx] = entry |
| 670 | 672 |
| … | … | @@ -26,6 +26,7 @@ function catalogAppsFromEnvelope (catalog) { |
| 26 | 26 | |
| 27 | 27 | const HEX64_RE = /^[0-9a-f]{64}$/i |
| 28 | 28 | const Z32_RE = /^[13-9a-km-uw-z]{52}$/i |
| 29 | const PEAR_V3_LINK_RE = /^pear:\/\/[13-9a-km-uw-z]{52}\/?$/i | |
| 29 | 30 | // Catalog links are browsable content only. Native delivery must be represented |
| 30 | 31 | // separately as a verified package or a non-executable migration record. |
| 31 | 32 | const APP_LINK_RE = /^hyper:\/\/.+/i |
| … | … | @@ -99,8 +100,29 @@ function normalizeLegacyMigrationId (value) { |
| 99 | 100 | } |
| 100 | 101 | |
| 101 | 102 | function 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 | } | |
| 104 | 126 | } |
| 105 | 127 | |
| 106 | 128 | function normalizeCatalogApp (app, opts = {}) { |
| … | … | @@ -117,8 +139,9 @@ function normalizeCatalogApp (app, opts = {}) { |
| 117 | 139 | const driveKey = normalizeDriveKey(key) || driveKeyFromHyperLink(link) |
| 118 | 140 | const legacyMigrationId = normalizeLegacyMigrationId(out.legacyMigrationId) |
| 119 | 141 | 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 | |
| 122 | 145 | |
| 123 | 146 | out.id = id || undefined |
| 124 | 147 | out.name = trimString(out.name) || id || undefined |
| … | … | @@ -164,6 +187,8 @@ function catalogAppStableKey (app) { |
| 164 | 187 | const driveKey = normalizeDriveKey(key) || driveKeyFromHyperLink(link) |
| 165 | 188 | if (driveKey) return `drive:${driveKey}` |
| 166 | 189 | if (link) return `link:${link}` |
| 190 | const nativeDelivery = normalizeNativeDelivery(app.nativeDelivery) | |
| 191 | if (nativeDelivery && nativeDelivery.status === 'available') return `native:${nativeDelivery.installLink}` | |
| 167 | 192 | const legacyMigrationId = normalizeLegacyMigrationId(app.legacyMigrationId) |
| 168 | 193 | if (legacyMigrationId) return `legacy:${legacyMigrationId}` |
| 169 | 194 | const id = trimString(app.id) |
| … | … | @@ -256,6 +281,10 @@ function catalogAppSearchText (app) { |
| 256 | 281 | app.verification, |
| 257 | 282 | app.legacyMigrationId, |
| 258 | 283 | 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 : []), | |
| 259 | 288 | app.link, |
| 260 | 289 | app.driveKey, |
| 261 | 290 | ...(Array.isArray(app.categories) ? app.categories : []), |
| … | … | @@ -279,6 +308,7 @@ function sanitizePersonalCatalogEntry (app) { |
| 279 | 308 | version: str(app.version, 40), |
| 280 | 309 | author: str(app.author, 200), |
| 281 | 310 | icon: str(app.icon, 300), |
| 311 | nativeDelivery: normalizeNativeDelivery(app.nativeDelivery), | |
| 282 | 312 | // Launch gating (PBACS §9): explicit standalone (own window) vs hypersite |
| 283 | 313 | // (inline tab). Only the two valid enum values survive; anything else drops. |
| 284 | 314 | type: (['standalone', 'hypersite'].includes(String(app.type || '').trim()) ? String(app.type).trim() : undefined) |
| … | … | @@ -288,7 +318,7 @@ function sanitizePersonalCatalogEntry (app) { |
| 288 | 318 | } |
| 289 | 319 | for (const k of Object.keys(draft)) if (draft[k] === undefined || draft[k] === '') delete draft[k] |
| 290 | 320 | 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.') | |
| 292 | 322 | return { |
| 293 | 323 | id: out.id, |
| 294 | 324 | name: out.name, |
| … | … | @@ -296,6 +326,7 @@ function sanitizePersonalCatalogEntry (app) { |
| 296 | 326 | description: out.description || '', |
| 297 | 327 | ...(out.driveKey ? { driveKey: out.driveKey } : {}), |
| 298 | 328 | ...(out.link ? { link: out.link } : {}), |
| 329 | ...(out.nativeDelivery ? { nativeDelivery: out.nativeDelivery } : {}), | |
| 299 | 330 | version: out.version || '', |
| 300 | 331 | author: out.author || '', |
| 301 | 332 | categories: Array.isArray(out.categories) ? out.categories : [], |
| … | … | @@ -32,6 +32,10 @@ const APPS_SCHEMA = { |
| 32 | 32 | link: { type: 'string', maxLength: 300 }, |
| 33 | 33 | legacyMigrationId: { type: 'string', pattern: '^[13-9a-km-uw-z]{52}$' }, |
| 34 | 34 | 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 }, | |
| 35 | 39 | iconData: { type: 'string', maxLength: 20000 }, |
| 36 | 40 | type: { enum: ['standalone', 'hypersite'] }, |
| 37 | 41 | author: { type: 'string', maxLength: 200 }, |
| … | … | @@ -45,7 +49,15 @@ const APPS_SCHEMA = { |
| 45 | 49 | verification: { enum: ['unverified', 'relay-listed', 'author-signed'] } |
| 46 | 50 | }, |
| 47 | 51 | 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 | ], | |
| 49 | 61 | additionalProperties: false |
| 50 | 62 | } |
| 51 | 63 | |
| … | … | @@ -117,7 +129,15 @@ function rowToApp (row) { |
| 117 | 129 | driveKey: j.driveKey || null, |
| 118 | 130 | link: j.link || null, |
| 119 | 131 | 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, | |
| 121 | 141 | type: j.type || (j.link && !j.driveKey ? 'standalone' : undefined), |
| 122 | 142 | author: j.author || null, |
| 123 | 143 | homepage: j.homepage || null, |
| … | … | @@ -596,7 +596,8 @@ application. |
| 596 | 596 | - **PB-RELEASE-2 (MUST).** The embedded runtime starts only a bundled local |
| 597 | 597 | entrypoint (for example `PearRuntime.run(require.resolve('./worker.js'))`). |
| 598 | 598 | 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. | |
| 600 | 601 | - **PB-RELEASE-3 (MUST).** Test clean install, upgrade, data preservation, |
| 601 | 602 | rollback, and local worker boot/shutdown for every supported target. A |
| 602 | 603 | checksum alone is insufficient release evidence. |
| … | … | @@ -615,7 +616,9 @@ application. |
| 615 | 616 | ### 13.3 Catalogue actions |
| 616 | 617 | |
| 617 | 618 | - **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 | |
| 619 | 622 | `pear://` or `file://` entry is legacy discovery metadata and must display |
| 620 | 623 | **migration required**, never an install or run action. |
| 621 | 624 | - **PB-RELEASE-7 (MUST).** Catalogue records include name, description, author, |
| … | … | @@ -7,11 +7,31 @@ that presents verified applications and installs as distinct user actions. |
| 7 | 7 | |
| 8 | 8 | - Install PearBrowser from a native package and verify the published checksum. |
| 9 | 9 | - 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 | ||
| 21 | An available native catalogue entry uses this shape (with the real published | |
| 22 | key 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 | ``` | |
| 15 | 35 | |
| 16 | 36 | ## Runtime boundary |
| 17 | 37 | |
| … | … | @@ -22,9 +42,20 @@ only the bundled `workers/main.js` through |
| 22 | 42 | the authenticated localhost RPC service. |
| 23 | 43 | |
| 24 | 44 | The renderer receives a freshly generated, per-launch session token through |
| 25 | the context-isolated preload bridge. It cannot invoke the runtime, install an | |
| 26 | application, or obtain Node integration. A remote catalogue link remains | |
| 27 | discovery metadata, never a `PearRuntime.run()` input. | |
| 45 | the context-isolated preload bridge. It cannot invoke the runtime or obtain | |
| 46 | Node integration. The preload exposes narrow native-app list/install/launch | |
| 47 | operations; Electron main validates the renderer, shows the OS-level install | |
| 48 | confirmation, and accepts no caller-controlled destination, binary filter, or | |
| 49 | DHT bootstrap configuration. A remote catalogue link remains discovery | |
| 50 | metadata, never a `PearRuntime.run()` input. | |
| 51 | ||
| 52 | The native launcher pins `pear-install@1.2.2`. It accepts one GUI artifact for | |
| 53 | the current OS (`.app`, `.AppImage`, or `.msix`), rejects packages exposing | |
| 54 | command-line binary targets, requires the package's `upgrade` identity to match | |
| 55 | the requested link and optional catalogue product name, and rejects unexpected | |
| 56 | install destinations or an incompatible declared platform target. The installed | |
| 57 | application then starts and configures its own embedded `pear-runtime`; the | |
| 58 | browser does not provide an ambient `Pear` global or runtime configuration. | |
| 28 | 59 | |
| 29 | 60 | The `package.json#upgrade` field is a v3 OTA deployment identity, not a |
| 30 | 61 | browser-launch affordance. It must move to the project’s production multisig |
| … | … | @@ -1,18 +1,34 @@ |
| 1 | 1 | // Native v3 host. Electron owns application lifecycle and Pear OTA owns only |
| 2 | 2 | // the embedded Bare backend/update worker; there is no `pear run` launch path. |
| 3 | const { app, BrowserWindow, ipcMain } = require('electron') | |
| 3 | const { app, BrowserWindow, dialog, ipcMain, shell } = require('electron') | |
| 4 | 4 | const crypto = require('node:crypto') |
| 5 | 5 | const os = require('node:os') |
| 6 | 6 | const path = require('node:path') |
| 7 | const { pathToFileURL } = require('node:url') | |
| 7 | 8 | const PearRuntime = require('pear-runtime') |
| 8 | 9 | const pkg = require('../package.json') |
| 10 | const { PearAppLauncher, normalizePearInstallLink } = require('./pear-app-launcher.cjs') | |
| 9 | 11 | |
| 10 | 12 | const sessionToken = crypto.randomBytes(32).toString('hex') |
| 11 | 13 | const appName = pkg.productName || pkg.name |
| 12 | 14 | let workerPipe = null |
| 13 | 15 | let pearRuntime = null |
| 16 | let pearAppLauncher = null | |
| 14 | 17 | let quitting = false |
| 15 | 18 | |
| 19 | function 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 | ||
| 27 | function requirePearAppLauncher () { | |
| 28 | if (!pearAppLauncher) throw new Error('Pear v3 app launcher is not ready') | |
| 29 | return pearAppLauncher | |
| 30 | } | |
| 31 | ||
| 16 | 32 | ipcMain.on('pearbrowser:runtime-session', (event) => { |
| 17 | 33 | event.returnValue = sessionToken |
| 18 | 34 | }) |
| … | … | @@ -21,6 +37,61 @@ ipcMain.handle('pearbrowser:open-devtools', (event) => { |
| 21 | 37 | event.sender.openDevTools({ mode: 'detach' }) |
| 22 | 38 | }) |
| 23 | 39 | |
| 40 | ipcMain.handle('pearbrowser:pear-apps:list', (event) => { | |
| 41 | requireTrustedRenderer(event) | |
| 42 | return requirePearAppLauncher().list() | |
| 43 | }) | |
| 44 | ||
| 45 | ipcMain.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 | ||
| 89 | ipcMain.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 | ||
| 24 | 95 | function storagePath () { |
| 25 | 96 | if (!app.isPackaged) return path.join(os.tmpdir(), 'pearbrowser-desktop') |
| 26 | 97 | return app.getPath('userData') |
| … | … | @@ -82,6 +153,10 @@ if (!singleInstance) { |
| 82 | 153 | app.quit() |
| 83 | 154 | } else { |
| 84 | 155 | app.whenReady().then(async () => { |
| 156 | pearAppLauncher = new PearAppLauncher({ | |
| 157 | dataFile: path.join(app.getPath('userData'), 'pear-v3-apps.json'), | |
| 158 | shell | |
| 159 | }) | |
| 85 | 160 | await startBackendWorker() |
| 86 | 161 | await createWindow() |
| 87 | 162 | app.on('activate', async () => { |
| … | … | @@ -0,0 +1,358 @@ |
| 1 | 'use strict' | |
| 2 | ||
| 3 | const crypto = require('node:crypto') | |
| 4 | const fs = require('node:fs') | |
| 5 | const os = require('node:os') | |
| 6 | const path = require('node:path') | |
| 7 | const { spawn, spawnSync } = require('node:child_process') | |
| 8 | ||
| 9 | const PEAR_KEY_RE = /^[13-9a-km-uw-z]{52}$/ | |
| 10 | const 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})?)$/ | |
| 11 | const SAFE_APP_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9 ._()+@-]{0,119}$/ | |
| 12 | const SAFE_PACKAGE_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,199}$/ | |
| 13 | ||
| 14 | function 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 | ||
| 32 | function 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 | ||
| 39 | function 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 | ||
| 46 | function 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 | ||
| 53 | function allowedLinuxRoots (homeDir) { | |
| 54 | return [ | |
| 55 | path.join(homeDir, 'Applications'), | |
| 56 | path.join(homeDir, 'AppImages'), | |
| 57 | path.join(homeDir, '.local', 'bin') | |
| 58 | ] | |
| 59 | } | |
| 60 | ||
| 61 | function 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 | ||
| 89 | function 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 | ||
| 141 | function 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 | ||
| 157 | class 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 | ||
| 352 | module.exports = { | |
| 353 | PearAppLauncher, | |
| 354 | normalizePearInstallLink, | |
| 355 | validateInstallEvent, | |
| 356 | validateResolvedTargets, | |
| 357 | guiExtension | |
| 358 | } |
| … | … | @@ -2,5 +2,14 @@ const { contextBridge, ipcRenderer } = require('electron') |
| 2 | 2 | |
| 3 | 3 | contextBridge.exposeInMainWorld('pearbrowserRuntime', { |
| 4 | 4 | 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 | } | |
| 6 | 15 | }) |
| … | … | @@ -39,6 +39,7 @@ |
| 39 | 39 | "p2p-hiverelay": "latest", |
| 40 | 40 | "p2p-hiverelay-client": "latest", |
| 41 | 41 | "p2p-hiverelay-verifier": "latest", |
| 42 | "pear-install": "1.2.2", | |
| 42 | 43 | "pear-runtime": "1.3.1", |
| 43 | 44 | "react": "^18.3.1", |
| 44 | 45 | "react-dom": "^18.3.1", |
| … | … | @@ -1674,22 +1675,19 @@ |
| 1674 | 1675 | } |
| 1675 | 1676 | }, |
| 1676 | 1677 | "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==", | |
| 1680 | 1681 | "license": "Apache-2.0", |
| 1681 | 1682 | "engines": { |
| 1682 | 1683 | "bare": ">=1.14.0" |
| 1683 | 1684 | } |
| 1684 | 1685 | }, |
| 1685 | 1686 | "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" | |
| 1693 | 1691 | }, |
| 1694 | 1692 | "node_modules/bare-pipe": { |
| 1695 | 1693 | "version": "4.2.2", |
| … | … | @@ -2070,34 +2068,6 @@ |
| 2070 | 2068 | "bare": "bin/bare.exe" |
| 2071 | 2069 | } |
| 2072 | 2070 | }, |
| 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 | }, | |
| 2101 | 2071 | "node_modules/bare-semver": { |
| 2102 | 2072 | "version": "1.0.3", |
| 2103 | 2073 | "resolved": "https://registry.npmjs.org/bare-semver/-/bare-semver-1.0.3.tgz", |
| … | … | @@ -2120,32 +2090,6 @@ |
| 2120 | 2090 | "require-asset": "^1.1.0" |
| 2121 | 2091 | } |
| 2122 | 2092 | }, |
| 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 | }, | |
| 2149 | 2093 | "node_modules/bare-signals": { |
| 2150 | 2094 | "version": "4.2.0", |
| 2151 | 2095 | "resolved": "https://registry.npmjs.org/bare-signals/-/bare-signals-4.2.0.tgz", |
| … | … | @@ -2245,6 +2189,32 @@ |
| 2245 | 2189 | "bare-process": "^4.2.1" |
| 2246 | 2190 | } |
| 2247 | 2191 | }, |
| 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 | }, | |
| 2248 | 2218 | "node_modules/bare-tcp": { |
| 2249 | 2219 | "version": "2.5.2", |
| 2250 | 2220 | "resolved": "https://registry.npmjs.org/bare-tcp/-/bare-tcp-2.5.2.tgz", |
| … | … | @@ -3269,16 +3239,25 @@ |
| 3269 | 3239 | } |
| 3270 | 3240 | }, |
| 3271 | 3241 | "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==", | |
| 3275 | 3245 | "license": "MIT", |
| 3276 | 3246 | "dependencies": { |
| 3277 | 3247 | "b4a": "^1.6.6", |
| 3278 | "compact-encoding": "^2.15.0", | |
| 3248 | "compact-encoding": "^3.0.0", | |
| 3279 | 3249 | "sodium-universal": "^5.0.0" |
| 3280 | 3250 | } |
| 3281 | 3251 | }, |
| 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 | }, | |
| 3282 | 3261 | "node_modules/hypercore-errors": { |
| 3283 | 3262 | "version": "1.5.0", |
| 3284 | 3263 | "resolved": "https://registry.npmjs.org/hypercore-errors/-/hypercore-errors-1.5.0.tgz", |
| … | … | @@ -3607,6 +3586,21 @@ |
| 3607 | 3586 | "integrity": "sha512-Yqi947Vk5Ps2YqhOV8K+RR6bseLhZLIVfovpWJH5cT7GE4Pca8/3iny/3oQ47scD7SfQd3whhxMfc+KgxTgDHA==", |
| 3608 | 3587 | "license": "MIT" |
| 3609 | 3588 | }, |
| 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 | }, | |
| 3610 | 3604 | "node_modules/lodash.camelcase": { |
| 3611 | 3605 | "version": "4.3.0", |
| 3612 | 3606 | "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", |
| … | … | @@ -4075,21 +4069,70 @@ |
| 4075 | 4069 | "bare-path": "*" |
| 4076 | 4070 | } |
| 4077 | 4071 | }, |
| 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 | }, | |
| 4087 | 4072 | "node_modules/pear-errors": { |
| 4088 | 4073 | "version": "1.0.1", |
| 4089 | 4074 | "resolved": "https://registry.npmjs.org/pear-errors/-/pear-errors-1.0.1.tgz", |
| 4090 | 4075 | "integrity": "sha512-KQyhym09QpLT4H/z3uLW0bqixgIZic2Eip2Q8Z72QmIpFzPtp6QmnzOUwB3kuHB4dqKNa+FQWHWH0NSK5lJjlw==", |
| 4091 | 4076 | "license": "Apache-2.0" |
| 4092 | 4077 | }, |
| 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 | }, | |
| 4093 | 4136 | "node_modules/pear-runtime": { |
| 4094 | 4137 | "version": "1.3.1", |
| 4095 | 4138 | "resolved": "https://registry.npmjs.org/pear-runtime/-/pear-runtime-1.3.1.tgz", |
| … | … | @@ -4125,33 +4168,6 @@ |
| 4125 | 4168 | "which-runtime": "^1.3.2" |
| 4126 | 4169 | } |
| 4127 | 4170 | }, |
| 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 | }, | |
| 4155 | 4171 | "node_modules/pino": { |
| 4156 | 4172 | "version": "9.14.0", |
| 4157 | 4173 | "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", |
| … | … | @@ -103,6 +103,7 @@ |
| 103 | 103 | "bip39-mnemonic": "^2.5.0", |
| 104 | 104 | "blind-pairing": "^2.3.1", |
| 105 | 105 | "corestore": "^7.9.2", |
| 106 | "electron": "43.2.0", | |
| 106 | 107 | "framed-stream": "^1.0.0", |
| 107 | 108 | "htm": "^3.1.1", |
| 108 | 109 | "hyperbee": "^2.27.3", |
| … | … | @@ -113,7 +114,7 @@ |
| 113 | 114 | "p2p-hiverelay": "latest", |
| 114 | 115 | "p2p-hiverelay-client": "latest", |
| 115 | 116 | "p2p-hiverelay-verifier": "latest", |
| 116 | "electron": "43.2.0", | |
| 117 | "pear-install": "1.2.2", | |
| 117 | 118 | "pear-runtime": "1.3.1", |
| 118 | 119 | "react": "^18.3.1", |
| 119 | 120 | "react-dom": "^18.3.1", |
| … | … | @@ -96,6 +96,47 @@ test('catalog app normalizer drops unsafe and executable targets', () => { |
| 96 | 96 | assert.equal(hyper.link, `hyper://${driveKey}/app`) |
| 97 | 97 | }) |
| 98 | 98 | |
| 99 | test('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 | ||
| 99 | 140 | test('shared catalog app normalizer builds stable target keys', () => { |
| 100 | 141 | const driveKey = key('d') |
| 101 | 142 | const app = normalizeCatalogApp({ |
| … | … | @@ -0,0 +1,208 @@ |
| 1 | import test from 'node:test' | |
| 2 | import assert from 'node:assert/strict' | |
| 3 | import { EventEmitter } from 'node:events' | |
| 4 | import launcherMod from '../electron/pear-app-launcher.cjs' | |
| 5 | ||
| 6 | const { PearAppLauncher, normalizePearInstallLink, validateInstallEvent, validateResolvedTargets } = launcherMod | |
| 7 | const KEY = 'a'.repeat(52) | |
| 8 | const LINK = `pear://${KEY}` | |
| 9 | ||
| 10 | function 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 | ||
| 23 | test('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 | ||
| 31 | test('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 | ||
| 72 | test('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 | ||
| 90 | test('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 | ||
| 129 | test('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 | ||
| 148 | test('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 | ||
| 181 | test('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 | }) |
| … | … | @@ -6,6 +6,7 @@ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), |
| 6 | 6 | const worker = readFileSync(new URL('../workers/main.js', import.meta.url), 'utf8') |
| 7 | 7 | const host = readFileSync(new URL('../electron/main.cjs', import.meta.url), 'utf8') |
| 8 | 8 | const preload = readFileSync(new URL('../electron/preload.cjs', import.meta.url), 'utf8') |
| 9 | const appLauncher = readFileSync(new URL('../electron/pear-app-launcher.cjs', import.meta.url), 'utf8') | |
| 9 | 10 | const backendEntry = readFileSync(new URL('../index.js', import.meta.url), 'utf8') |
| 10 | 11 | const boot = readFileSync(new URL('../ui/boot.js', import.meta.url), 'utf8') |
| 11 | 12 | |
| … | … | @@ -13,6 +14,7 @@ test('PearBrowser v3 uses an embedded Pear OTA worker behind a native Electron h |
| 13 | 14 | assert.equal(pkg.main, 'electron/main.cjs') |
| 14 | 15 | assert.equal(pkg.scripts.start, 'electron electron/main.cjs') |
| 15 | 16 | assert.equal(pkg.dependencies?.['pear-runtime'], '1.3.1') |
| 17 | assert.equal(pkg.dependencies?.['pear-install'], '1.2.2') | |
| 16 | 18 | assert.equal(pkg.dependencies?.electron, '43.2.0') |
| 17 | 19 | assert.match(pkg.upgrade, /^pear:\/\/[13-9a-km-uw-z]{52}$/) |
| 18 | 20 | |
| … | … | @@ -24,6 +26,15 @@ test('PearBrowser v3 uses an embedded Pear OTA worker behind a native Electron h |
| 24 | 26 | assert.match(host, /contextIsolation: true/) |
| 25 | 27 | assert.match(host, /nodeIntegration: false/) |
| 26 | 28 | 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/) | |
| 27 | 38 | assert.match(worker, /Bare\.argv\[2\]/) |
| 28 | 39 | assert.match(worker, /PearBrowserRuntime = \{ storagePath, sessionToken \}/) |
| 29 | 40 | assert.match(backendEntry, /globalThis\.PearBrowserRuntime/) |
| … | … | @@ -6,7 +6,34 @@ import test from 'node:test' |
| 6 | 6 | import assert from 'node:assert/strict' |
| 7 | 7 | import sheetsCatalog from '../backend/sheets-catalog.js' |
| 8 | 8 | |
| 9 | const { SheetsCatalog, safeJmesPath, MAX_QUERY_LEN, MAX_SHEETS_ROWS } = sheetsCatalog | |
| 9 | const { SheetsCatalog, APPS_SCHEMA, rowToApp, safeJmesPath, MAX_QUERY_LEN, MAX_SHEETS_ROWS } = sheetsCatalog | |
| 10 | ||
| 11 | test('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 | }) | |
| 10 | 37 | |
| 11 | 38 | test('safeJmesPath treats empty/absent query as "no filter"', () => { |
| 12 | 39 | assert.equal(safeJmesPath(undefined), undefined) |
| … | … | @@ -2691,6 +2691,8 @@ function appStableDedupeKey (app) { |
| 2691 | 2691 | const hyperKey = /^hyper:\/\//i.test(link) ? driveKeyFromHyperRef(link) : '' |
| 2692 | 2692 | if (driveKey || hyperKey) return 'drive:' + (driveKey || hyperKey) |
| 2693 | 2693 | 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 | |
| 2694 | 2696 | const legacyMigrationId = String(app.legacyMigrationId || '').trim().toLowerCase() |
| 2695 | 2697 | if (/^[13-9a-km-uw-z]{52}$/.test(legacyMigrationId)) return 'legacy:' + legacyMigrationId |
| 2696 | 2698 | const id = String(app.id || '').trim() |
| … | … | @@ -2794,6 +2796,8 @@ function Apps ({ rpc, C, onLaunch }) { |
| 2794 | 2796 | // via user-data settings so they survive across launches. |
| 2795 | 2797 | const [recentCatalogs, setRecentCatalogs] = useState([]) |
| 2796 | 2798 | const [installed, setInstalled] = useState([]) |
| 2799 | const [nativeApps, setNativeApps] = useState([]) | |
| 2800 | const [nativeProgress, setNativeProgress] = useState(null) | |
| 2797 | 2801 | const [busy, setBusy] = useState(null) |
| 2798 | 2802 | const [err, setErr] = useState('') |
| 2799 | 2803 | const [autoLoadAttempted, setAutoLoadAttempted] = useState(false) |
| … | … | @@ -2885,6 +2889,78 @@ function Apps ({ rpc, C, onLaunch }) { |
| 2885 | 2889 | } |
| 2886 | 2890 | } |
| 2887 | 2891 | |
| 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 | ||
| 2888 | 2964 | // Ask the backend which installed apps are behind the loaded catalog's |
| 2889 | 2965 | // version. Non-critical — a failure just means no update badges. |
| 2890 | 2966 | const refreshUpdates = async () => { |
| … | … | @@ -3141,6 +3217,7 @@ function Apps ({ rpc, C, onLaunch }) { |
| 3141 | 3217 | // the aggregated store is populated, not just the most recent one. |
| 3142 | 3218 | useEffect(() => { |
| 3143 | 3219 | refreshInstalled() |
| 3220 | refreshNativeApps() | |
| 3144 | 3221 | // Pull the aggregated store immediately so backend-registered catalogues |
| 3145 | 3222 | // (e.g. the default schema-sheets catalogue, seeded on boot) show up without |
| 3146 | 3223 | // waiting on a recent/relay catalog load to resolve. |
| … | … | @@ -3223,6 +3300,12 @@ function Apps ({ rpc, C, onLaunch }) { |
| 3223 | 3300 | // eslint-disable-next-line react-hooks/exhaustive-deps |
| 3224 | 3301 | }, []) |
| 3225 | 3302 | |
| 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 | ||
| 3226 | 3309 | const installApp = async (app) => { |
| 3227 | 3310 | setErr(''); setBusy(`install:${app.id}`) |
| 3228 | 3311 | try { |
| … | … | @@ -3273,7 +3356,7 @@ function Apps ({ rpc, C, onLaunch }) { |
| 3273 | 3356 | const q = query.normalize('NFKC').trim().toLowerCase() |
| 3274 | 3357 | const matched = apps.filter((a) => { |
| 3275 | 3358 | // 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 | |
| 3277 | 3360 | if (source !== 'all' && a.catalogKey !== source) return false |
| 3278 | 3361 | if (category !== 'all' && !appCategories(a).includes(category)) return false |
| 3279 | 3362 | if (!q) return true |
| … | … | @@ -3284,7 +3367,7 @@ function Apps ({ rpc, C, onLaunch }) { |
| 3284 | 3367 | }, [apps, query, category, source]) |
| 3285 | 3368 | |
| 3286 | 3369 | // 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]) | |
| 3288 | 3371 | |
| 3289 | 3372 | const renderMyCatalogApp = (app) => { |
| 3290 | 3373 | const savedId = app.id || app.driveKey || app.name || 'untitled' |
| … | … | @@ -3395,6 +3478,15 @@ function Apps ({ rpc, C, onLaunch }) { |
| 3395 | 3478 | <p className="placeholder">Older remote app links cannot run in PearBrowser. Install only a publisher-provided, verified native v3 package.</p> |
| 3396 | 3479 | </div> |
| 3397 | 3480 | ${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>`} | |
| 3398 | 3490 | |
| 3399 | 3491 | <h2>App Catalog</h2> |
| 3400 | 3492 | <div className="catalog-loader"> |
| … | … | @@ -3490,20 +3582,28 @@ function Apps ({ rpc, C, onLaunch }) { |
| 3490 | 3582 | ${app.version ? 'v' + app.version : ''} ${app.author ? '· ' + app.author : ''} |
| 3491 | 3583 | ${app.nativeDelivery?.status === 'migration-required' |
| 3492 | 3584 | ? 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>` : ''))} | |
| 3494 | 3588 | </div> |
| 3495 | 3589 | ${app.catalogName && html`<div className="app-source-tag">${app.catalogName}</div>`} |
| 3496 | 3590 | <${AppMeta} rpc=${rpc} C=${C} app=${app} /> |
| 3497 | 3591 | </div> |
| 3498 | 3592 | <div className="app-actions"> |
| 3499 | 3593 | ${(() => { |
| 3594 | const installLink = nativeInstallLink(app) | |
| 3595 | const nativeRecord = nativeRecordFor(app) | |
| 3500 | 3596 | return html` |
| 3501 | 3597 | ${app.driveKey && /^[0-9a-f]{64}$/i.test(app.driveKey) |
| 3502 | 3598 | ? html`<button key="open-page" className="btn subtle" onClick=${() => openSite(app)} title="Open this app's P2P page in a tab">Open page</button>` |
| 3503 | 3599 | : ''} |
| 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>`)} | |
| 3507 | 3607 | ${canEditMyCatalog && app.catalogKey !== myCatalog.keyHex && !inMyCatalog([app.id, app.driveKey, app.link]) && html` |
| 3508 | 3608 | <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> |
| 3509 | 3609 | `} |
| … | … | @@ -3582,9 +3682,27 @@ function Apps ({ rpc, C, onLaunch }) { |
| 3582 | 3682 | </div> |
| 3583 | 3683 | `} |
| 3584 | 3684 | |
| 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> | |
| 3586 | 3704 | ${installed.length === 0 |
| 3587 | ? html`<p className="placeholder">No apps installed yet.</p>` | |
| 3705 | ? html`<p className="placeholder">No Hyperdrive sites installed yet.</p>` | |
| 3588 | 3706 | : html`<div className="app-grid"> |
| 3589 | 3707 | ${installed.map((app) => html` |
| 3590 | 3708 | <div className="app-card" key=${app.id}> |
| … | … | @@ -3640,13 +3758,14 @@ function Apps ({ rpc, C, onLaunch }) { |
| 3640 | 3758 | ${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>`)} |
| 3641 | 3759 | </div>` : ''} |
| 3642 | 3760 | <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> | |
| 3644 | 3762 | ${detailApp.version ? html`<div><strong style=${{ color: '#c9d1d9' }}>Version:</strong> v${detailApp.version}</div>` : ''} |
| 3645 | 3763 | <div><strong style=${{ color: '#c9d1d9' }}>Verification:</strong> ${detailApp.verification || 'unverified'}</div> |
| 3646 | 3764 | ${detailApp.homepage ? html`<div style=${{ wordBreak: 'break-all' }}><strong style=${{ color: '#c9d1d9' }}>Homepage:</strong> ${detailApp.homepage}</div>` : ''} |
| 3647 | 3765 | ${detailApp.sourceUrl ? html`<div style=${{ wordBreak: 'break-all' }}><strong style=${{ color: '#c9d1d9' }}>Source:</strong> ${detailApp.sourceUrl}</div>` : ''} |
| 3648 | 3766 | ${detailApp.license ? html`<div><strong style=${{ color: '#c9d1d9' }}>License:</strong> ${detailApp.license}</div>` : ''} |
| 3649 | 3767 | ${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>` : ''} | |
| 3650 | 3769 | ${detailApp.driveKey ? html`<div style=${{ wordBreak: 'break-all' }}><strong style=${{ color: '#c9d1d9' }}>Drive:</strong> ${detailApp.driveKey}</div>` : ''} |
| 3651 | 3770 | ${(detailApp._sources && detailApp._sources.length) |
| 3652 | 3771 | ? 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 }) { |
| 3654 | 3773 | ${detailApp.publisherKey ? html`<div style=${{ wordBreak: 'break-all' }}><strong style=${{ color: '#c9d1d9' }}>Publisher:</strong> ${shortKey(detailApp.publisherKey)}</div>` : ''} |
| 3655 | 3774 | </div> |
| 3656 | 3775 | <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>`)} | |
| 3666 | 3791 | <button key="detail-close" className="btn" onClick=${() => setDetailApp(null)}>Close</button> |
| 3667 | 3792 | </div> |
| 3668 | 3793 | </div> |