| … | … | @@ -1,5 +1,32 @@ |
| 1 | 1 | # Changelog |
| 2 | 2 | |
| 3 | ## v0.7.1 — 2026-07-16 | |
| 4 | ||
| 5 | Corrective desktop transport release. The backend RPC handlers were healthy, | |
| 6 | but a renderer WebSocket interrupted during a reload or live-update handoff | |
| 7 | left the mounted UI writing into a dead pipe; unrelated Settings calls then | |
| 8 | surfaced as independent 30-second command timeouts. | |
| 9 | ||
| 10 | ### Fixed | |
| 11 | ||
| 12 | - Pending renderer RPC calls now reject immediately when the WebSocket closes | |
| 13 | or errors, and new calls fail clearly while the backend is reconnecting. | |
| 14 | - The authenticated renderer can reconnect during an eight-second grace period | |
| 15 | without losing the backend, Corestore session, or buffered backend events. | |
| 16 | - Failed initial port candidates are explicitly destroyed, preventing a late | |
| 17 | connection from claiming the single renderer slot. | |
| 18 | - The desktop shell now shows a real reconnect/resume screen and remounts the | |
| 19 | application after the handshake is restored; exhausted retries give a safe | |
| 20 | relaunch instruction without suggesting profile deletion. | |
| 21 | ||
| 22 | ### Verification | |
| 23 | ||
| 24 | - The complete desktop suite passes `681/681`, including five new transport | |
| 25 | lifecycle tests. | |
| 26 | - A controlled Pear renderer reload exercised the backend handoff and recorded | |
| 27 | an authenticated reconnect inside the grace window. Afterward, identity, | |
| 28 | profile, permissions, and relay RPCs all answered in `1–2 ms`. | |
| 29 | ||
| 3 | 30 | ## v0.7.0 — 2026-07-16 |
| 4 | 31 | |
| 5 | 32 | Swarm-distributed browser protections and extensions. Content Shield lists, |
| … | … | @@ -4,21 +4,21 @@ A local-first peer-to-peer browser, app store, search engine, naming layer, Nost |
| 4 | 4 | |
| 5 | 5 | **No accounts. No DNS. No manual app updates.** Sites and apps are addressed by stable Pear/Hyperdrive keys and pinned 24/7 on the [HiveRelay](https://github.com/bigdestiny2/P2P-Hiverelay) backbone. The publisher's laptop being offline doesn't matter — the relays carry the bytes, and users launch the current release from the catalogue without hunting for a download or applying an updater. |
| 6 | 6 | |
| 7 | **Current release:** `v0.7.0` · stable Pear production length `78006` (from `63165`). This release ships swarm-distributed Content Shield lists, capability-gated Pear Plugins, and a one-click P2P plugin catalogue. The default filter list, both example plugins, and the catalogue are published, pinned, and fresh-peer verified; their stable keys are in [the distribution guide](./filter-lists/README.md). The production drive is re-announced by its persistent publisher seeder, with live remote replication peers confirmed after release; the local cold-reader blob probe is a documented same-NAT false negative for this firewalled, history-heavy key. Desktop packages default to npm `latest` for HiveRelay, with the current dist-tag resolving to `0.20.2` and runtime compatibility checked through relay capability documents. See [docs/RELEASE_v0.7.0_OPERATOR.md](./docs/RELEASE_v0.7.0_OPERATOR.md) for the coordinated publish record. | |
| 7 | **Current release candidate:** `v0.7.1`. This corrective release makes the renderer/backend RPC transport survive reload and live-update handoffs: pending calls fail immediately on disconnect, the per-launch authenticated socket reconnects during a bounded grace window, and the shell visibly resumes instead of reporting unrelated 30-second command timeouts. It retains all `v0.7.0` Content Shield, Pear Plugins, and P2P catalogue features. See [docs/RELEASE_v0.7.1_OPERATOR.md](./docs/RELEASE_v0.7.1_OPERATOR.md) for the coordinated publish record. | |
| 8 | 8 | |
| 9 | 9 | **Current architecture:** start with [docs/ARCHITECTURE_AND_CAPABILITIES.md](./docs/ARCHITECTURE_AND_CAPABILITIES.md). The deeper catalogue/search/naming/Nostr audit is in [docs/DEEP_AUDIT_CATALOG_SEARCH_NAMING_NOSTR_2026-06-21.md](./docs/DEEP_AUDIT_CATALOG_SEARCH_NAMING_NOSTR_2026-06-21.md). |
| 10 | 10 | |
| 11 | 11 | ## Install it |
| 12 | 12 | |
| 13 | 13 | Primary desktop distribution is now native GitHub release packages. The |
| 14 | `v0.7.0` release targets macOS, Windows, and Linux assets with SHA-256 sidecars | |
| 14 | `v0.7.1` release targets macOS, Windows, and Linux assets with SHA-256 sidecars | |
| 15 | 15 | and platform manifests. Download from the |
| 16 | [`v0.7.0` release](https://github.com/bigdestiny2/pearbrowser-desktop/releases/tag/v0.7.0), | |
| 16 | [`v0.7.1` release](https://github.com/bigdestiny2/pearbrowser-desktop/releases/tag/v0.7.1), | |
| 17 | 17 | follow the [native install guide](./docs/INSTALL_NATIVE_PACKAGES.md), or resolve |
| 18 | 18 | the recommended asset for your machine from a source checkout: |
| 19 | 19 | |
| 20 | 20 | ```sh |
| 21 | npm run resolve:native-release -- --tag v0.7.0 --repo bigdestiny2/pearbrowser-desktop | |
| 21 | npm run resolve:native-release -- --tag v0.7.1 --repo bigdestiny2/pearbrowser-desktop | |
| 22 | 22 | ``` |
| 23 | 23 | |
| 24 | 24 | The current package targets match the `cmake-pear` appling toolchain: |
| … | … | @@ -224,29 +224,29 @@ node scripts/verify-live-catalog.js --expect-app peercord --expect-app peerit -- |
| 224 | 224 | |
| 225 | 225 | ## Distribution |
| 226 | 226 | |
| 227 | The `appling/` directory contains the multi-architecture native shell — Bare + CMake builds for macOS / Windows / Linux. GitHub release assets are produced by `.github/workflows/desktop-native-release.yml`, which builds the appling on hosted macOS, Windows, and Linux runners, collects the native artifacts, writes SHA-256 sidecars, and attaches them to the matching release tag. Run the workflow manually with tag `v0.7.0` and `source_ref` set to the release commit to produce or refresh the attached release assets. | |
| 227 | The `appling/` directory contains the multi-architecture native shell — Bare + CMake builds for macOS / Windows / Linux. GitHub release assets are produced by `.github/workflows/desktop-native-release.yml`, which builds the appling on hosted macOS, Windows, and Linux runners, collects the native artifacts, writes SHA-256 sidecars, and attaches them to the matching release tag. Run the workflow manually with tag `v0.7.1` and `source_ref` set to the release commit to produce or refresh the attached release assets. | |
| 228 | 228 | |
| 229 | 229 | Current generated artifacts are `.app.zip` on macOS, `.msix` on Windows, and `.AppImage` on Linux. The workflow uses `npm ci --prefix appling`, so update `appling/package-lock.json` deliberately when the native wrapper toolchain changes. |
| 230 | 230 | |
| 231 | 231 | ```sh |
| 232 | npm run check:appling-release -- --tag v0.7.0 | |
| 232 | npm run check:appling-release -- --tag v0.7.1 | |
| 233 | 233 | npm run check:linux-appimage-metadata |
| 234 | npm run resolve:native-release -- --tag v0.7.0 --repo bigdestiny2/pearbrowser-desktop | |
| 235 | npm run -s generate:native-signing-secret-plan -- --repo bigdestiny2/pearbrowser-desktop --tag v0.7.0 --source-ref <release-commit> | |
| 236 | npm run -s generate:native-install-snippet -- --tag v0.7.0 --repo bigdestiny2/pearbrowser-desktop | |
| 237 | npm run -s generate:native-install-guide -- --tag v0.7.0 --repo bigdestiny2/pearbrowser-desktop | |
| 238 | npm run -s generate:native-install-smoke-plan -- --tag v0.7.0 --repo bigdestiny2/pearbrowser-desktop --source-ref <release-commit> | |
| 239 | npm run generate:package-manager-manifests -- --tag v0.7.0 --repo bigdestiny2/pearbrowser-desktop --trust-mode package-proof | |
| 234 | npm run resolve:native-release -- --tag v0.7.1 --repo bigdestiny2/pearbrowser-desktop | |
| 235 | npm run -s generate:native-signing-secret-plan -- --repo bigdestiny2/pearbrowser-desktop --tag v0.7.1 --source-ref <release-commit> | |
| 236 | npm run -s generate:native-install-snippet -- --tag v0.7.1 --repo bigdestiny2/pearbrowser-desktop | |
| 237 | npm run -s generate:native-install-guide -- --tag v0.7.1 --repo bigdestiny2/pearbrowser-desktop | |
| 238 | npm run -s generate:native-install-smoke-plan -- --tag v0.7.1 --repo bigdestiny2/pearbrowser-desktop --source-ref <release-commit> | |
| 239 | npm run generate:package-manager-manifests -- --tag v0.7.1 --repo bigdestiny2/pearbrowser-desktop --trust-mode package-proof | |
| 240 | 240 | npm run check:native-signing -- --require-public-trust --secret-source github --repo bigdestiny2/pearbrowser-desktop |
| 241 | npm run check:public-trust-readiness -- --tag v0.7.0 --repo bigdestiny2/pearbrowser-desktop --source-ref <release-commit> --signing-secret-source github | |
| 242 | npm run -s generate:public-trust-operator-report -- --tag v0.7.0 --repo bigdestiny2/pearbrowser-desktop --source-ref <release-commit> --signing-secret-source github | |
| 241 | npm run check:public-trust-readiness -- --tag v0.7.1 --repo bigdestiny2/pearbrowser-desktop --source-ref <release-commit> --signing-secret-source github | |
| 242 | npm run -s generate:public-trust-operator-report -- --tag v0.7.1 --repo bigdestiny2/pearbrowser-desktop --source-ref <release-commit> --signing-secret-source github | |
| 243 | 243 | npm run -s generate:release-evidence-handoff |
| 244 | 244 | cd appling |
| 245 | 245 | npm ci |
| 246 | 246 | npm run generate |
| 247 | 247 | npm run build |
| 248 | 248 | cd .. |
| 249 | npm run package:appling -- --tag v0.7.0 | |
| 249 | npm run package:appling -- --tag v0.7.1 | |
| 250 | 250 | ``` |
| 251 | 251 | |
| 252 | 252 | Code signing is per-platform: |
| … | … | @@ -332,7 +332,7 @@ add_pear_appling( |
| 332 | 332 | pearbrowser_desktop |
| 333 | 333 | ID "tco5k7h38uoxatedp1wongdbhjxow1x7jiwm3t1i9cujbebhsbty" |
| 334 | 334 | NAME "PearBrowser" |
| 335 | VERSION 0.7.0 | |
| 335 | VERSION 0.7.1 | |
| 336 | 336 | AUTHOR "pearbrowser-desktop contributors" |
| 337 | 337 | DESCRIPTION "P2P browser, app store, and publishing platform — no servers required." |
| 338 | 338 |
| … | … | @@ -20,6 +20,7 @@ |
| 20 | 20 | </categories> |
| 21 | 21 | <url type="homepage">https://github.com/bigdestiny2/pearbrowser-desktop</url> |
| 22 | 22 | <releases> |
| 23 | <release version="0.7.1" date="2026-07-16"/> | |
| 23 | 24 | <release version="0.7.0" date="2026-07-16"/> |
| 24 | 25 | <release version="0.6.1" date="2026-07-15"/> |
| 25 | 26 | <release version="0.6.0" date="2026-07-13"/> |
| … | … | @@ -26,7 +26,7 @@ module.exports = { |
| 26 | 26 | "p2p" |
| 27 | 27 | ], |
| 28 | 28 | "description": "P2P browser, decentralized app store, and site publisher. macOS / Windows / Linux. Talks to peers directly over Hyperswarm. Pinned 24/7 on HiveRelay.", |
| 29 | "version": "0.7.0", | |
| 29 | "version": "0.7.1", | |
| 30 | 30 | "homepage": "hyper://03f0060a35451cfb6b68ad1dda1b8474ebb43fd9100071ccf7d67679a83ebb4f/", |
| 31 | 31 | "sourceUrl": "https://github.com/bigdestiny2/pearbrowser-desktop", |
| 32 | 32 | "license": "MIT", |
| … | … | @@ -1,7 +1,7 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "PearBrowser Network", |
| 3 | 3 | "description": "Curated entry point for the Pear ecosystem — the browser itself plus its companion sites.", |
| 4 | "version": 8, | |
| 4 | "version": 9, | |
| 5 | 5 | "publishedAt": "2026-07-16T00:00:00Z", |
| 6 | 6 | "publisher": "bigdestiny2", |
| 7 | 7 | "apps": [ |
| … | … | @@ -10,7 +10,7 @@ |
| 10 | 10 | "name": "PearBrowser Desktop", |
| 11 | 11 | "description": "P2P browser, decentralized app store, and site publisher. macOS / Windows / Linux. Talks to peers directly over Hyperswarm. Pinned 24/7 on HiveRelay.", |
| 12 | 12 | "author": "bigdestiny2", |
| 13 | "version": "0.7.0", | |
| 13 | "version": "0.7.1", | |
| 14 | 14 | "categories": [ |
| 15 | 15 | "browser", |
| 16 | 16 | "tools", |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | # Install Native Packages |
| 2 | 2 | |
| 3 | Current release: `v0.7.0`. | |
| 3 | Current release: `v0.7.1`. | |
| 4 | 4 | |
| 5 | 5 | These are package-proof desktop builds. Linux uses checksums only. macOS is |
| 6 | 6 | ad-hoc signed but not notarized, and Windows packages are unsigned until the |
| … | … | @@ -12,14 +12,14 @@ PearBrowser is free of malware until a signed and notarized DMG is published. |
| 12 | 12 | ## Choose A Package |
| 13 | 13 | |
| 14 | 14 | Download directly from the |
| 15 | [`v0.7.0` GitHub release](https://github.com/bigdestiny2/pearbrowser-desktop/releases/tag/v0.7.0). | |
| 15 | [`v0.7.1` GitHub release](https://github.com/bigdestiny2/pearbrowser-desktop/releases/tag/v0.7.1). | |
| 16 | 16 | |
| 17 | 17 | | Machine | Recommended package | Checksum sidecar | |
| 18 | 18 | | --- | --- | --- | |
| 19 | | macOS Apple Silicon | [PearBrowser-0.7.0-macos-arm64.app.zip](https://github.com/bigdestiny2/pearbrowser-desktop/releases/download/v0.7.0/PearBrowser-0.7.0-macos-arm64.app.zip) | [PearBrowser-0.7.0-macos-arm64.app.zip.sha256](https://github.com/bigdestiny2/pearbrowser-desktop/releases/download/v0.7.0/PearBrowser-0.7.0-macos-arm64.app.zip.sha256) | | |
| 20 | | macOS Intel | [PearBrowser-0.7.0-macos-x64.app.zip](https://github.com/bigdestiny2/pearbrowser-desktop/releases/download/v0.7.0/PearBrowser-0.7.0-macos-x64.app.zip) | [PearBrowser-0.7.0-macos-x64.app.zip.sha256](https://github.com/bigdestiny2/pearbrowser-desktop/releases/download/v0.7.0/PearBrowser-0.7.0-macos-x64.app.zip.sha256) | | |
| 21 | | Windows x64 | [PearBrowser-0.7.0-windows-x64.msix](https://github.com/bigdestiny2/pearbrowser-desktop/releases/download/v0.7.0/PearBrowser-0.7.0-windows-x64.msix) | [PearBrowser-0.7.0-windows-x64.msix.sha256](https://github.com/bigdestiny2/pearbrowser-desktop/releases/download/v0.7.0/PearBrowser-0.7.0-windows-x64.msix.sha256) | | |
| 22 | | Linux x64 | [PearBrowser-0.7.0-linux-x64.AppImage](https://github.com/bigdestiny2/pearbrowser-desktop/releases/download/v0.7.0/PearBrowser-0.7.0-linux-x64.AppImage) | [PearBrowser-0.7.0-linux-x64.AppImage.sha256](https://github.com/bigdestiny2/pearbrowser-desktop/releases/download/v0.7.0/PearBrowser-0.7.0-linux-x64.AppImage.sha256) | | |
| 19 | | macOS Apple Silicon | [PearBrowser-0.7.1-macos-arm64.app.zip](https://github.com/bigdestiny2/pearbrowser-desktop/releases/download/v0.7.1/PearBrowser-0.7.1-macos-arm64.app.zip) | [PearBrowser-0.7.1-macos-arm64.app.zip.sha256](https://github.com/bigdestiny2/pearbrowser-desktop/releases/download/v0.7.1/PearBrowser-0.7.1-macos-arm64.app.zip.sha256) | | |
| 20 | | macOS Intel | [PearBrowser-0.7.1-macos-x64.app.zip](https://github.com/bigdestiny2/pearbrowser-desktop/releases/download/v0.7.1/PearBrowser-0.7.1-macos-x64.app.zip) | [PearBrowser-0.7.1-macos-x64.app.zip.sha256](https://github.com/bigdestiny2/pearbrowser-desktop/releases/download/v0.7.1/PearBrowser-0.7.1-macos-x64.app.zip.sha256) | | |
| 21 | | Windows x64 | [PearBrowser-0.7.1-windows-x64.msix](https://github.com/bigdestiny2/pearbrowser-desktop/releases/download/v0.7.1/PearBrowser-0.7.1-windows-x64.msix) | [PearBrowser-0.7.1-windows-x64.msix.sha256](https://github.com/bigdestiny2/pearbrowser-desktop/releases/download/v0.7.1/PearBrowser-0.7.1-windows-x64.msix.sha256) | | |
| 22 | | Linux x64 | [PearBrowser-0.7.1-linux-x64.AppImage](https://github.com/bigdestiny2/pearbrowser-desktop/releases/download/v0.7.1/PearBrowser-0.7.1-linux-x64.AppImage) | [PearBrowser-0.7.1-linux-x64.AppImage.sha256](https://github.com/bigdestiny2/pearbrowser-desktop/releases/download/v0.7.1/PearBrowser-0.7.1-linux-x64.AppImage.sha256) | | |
| 23 | 23 | |
| 24 | 24 | The Linux release contains exactly one product AppImage. Packaging now rejects |
| 25 | 25 | ambiguous AppImages and excludes build tools such as AppImageTool. |
| … | … | @@ -31,26 +31,26 @@ native release workflow. |
| 31 | 31 | From a source checkout, ask the resolver for the current machine: |
| 32 | 32 | |
| 33 | 33 | ```sh |
| 34 | npm run resolve:native-release -- --tag v0.7.0 --repo bigdestiny2/pearbrowser-desktop | |
| 34 | npm run resolve:native-release -- --tag v0.7.1 --repo bigdestiny2/pearbrowser-desktop | |
| 35 | 35 | ``` |
| 36 | 36 | |
| 37 | 37 | Or specify a target: |
| 38 | 38 | |
| 39 | 39 | ```sh |
| 40 | npm run resolve:native-release -- --tag v0.7.0 --repo bigdestiny2/pearbrowser-desktop --platform macos --arch x64 | |
| 40 | npm run resolve:native-release -- --tag v0.7.1 --repo bigdestiny2/pearbrowser-desktop --platform macos --arch x64 | |
| 41 | 41 | ``` |
| 42 | 42 | |
| 43 | 43 | Release operators can verify every recommended package download and checksum |
| 44 | 44 | sidecar in one pass: |
| 45 | 45 | |
| 46 | 46 | ```sh |
| 47 | npm run verify:native-downloads -- --tag v0.7.0 --repo bigdestiny2/pearbrowser-desktop --all | |
| 47 | npm run verify:native-downloads -- --tag v0.7.1 --repo bigdestiny2/pearbrowser-desktop --all | |
| 48 | 48 | ``` |
| 49 | 49 | |
| 50 | 50 | Release operators can regenerate this guide from the same resolver rules: |
| 51 | 51 | |
| 52 | 52 | ```sh |
| 53 | npm run -s generate:native-install-guide -- --tag v0.7.0 --repo bigdestiny2/pearbrowser-desktop | |
| 53 | npm run -s generate:native-install-guide -- --tag v0.7.1 --repo bigdestiny2/pearbrowser-desktop | |
| 54 | 54 | ``` |
| 55 | 55 | |
| 56 | 56 | ## Verify The Download |
| … | … | @@ -58,7 +58,7 @@ npm run -s generate:native-install-guide -- --tag v0.7.0 --repo bigdestiny2/pear |
| 58 | 58 | macOS and Linux: |
| 59 | 59 | |
| 60 | 60 | ```sh |
| 61 | shasum -a 256 -c PearBrowser-0.7.0-macos-arm64.app.zip.sha256 | |
| 61 | shasum -a 256 -c PearBrowser-0.7.1-macos-arm64.app.zip.sha256 | |
| 62 | 62 | ``` |
| 63 | 63 | |
| 64 | 64 | Use the matching filename for your package. A passing check prints `OK`. |
| … | … | @@ -66,7 +66,7 @@ Use the matching filename for your package. A passing check prints `OK`. |
| 66 | 66 | Windows PowerShell: |
| 67 | 67 | |
| 68 | 68 | ```powershell |
| 69 | $package = "PearBrowser-0.7.0-windows-x64.msix" | |
| 69 | $package = "PearBrowser-0.7.1-windows-x64.msix" | |
| 70 | 70 | $expected = (Get-Content "$($package).sha256").Split(" ")[0].ToLowerInvariant() |
| 71 | 71 | $actual = (Get-FileHash $package -Algorithm SHA256).Hash.ToLowerInvariant() |
| 72 | 72 | if ($actual -ne $expected) { throw "SHA-256 mismatch for $package" } |
| … | … | @@ -88,7 +88,7 @@ macOS: |
| 88 | 88 | |
| 89 | 89 | Windows: |
| 90 | 90 | |
| 91 | 1. Install `PearBrowser-0.7.0-windows-x64.msix`. | |
| 91 | 1. Install `PearBrowser-0.7.1-windows-x64.msix`. | |
| 92 | 92 | 2. For package-proof builds, Windows SmartScreen may warn because the installer |
| 93 | 93 | is not yet Authenticode-signed. Continue only if you intentionally trust this |
| 94 | 94 | package and its checksum. |
| … | … | @@ -96,8 +96,8 @@ Windows: |
| 96 | 96 | Linux: |
| 97 | 97 | |
| 98 | 98 | ```sh |
| 99 | chmod +x PearBrowser-0.7.0-linux-x64.AppImage | |
| 100 | ./PearBrowser-0.7.0-linux-x64.AppImage | |
| 99 | chmod +x PearBrowser-0.7.1-linux-x64.AppImage | |
| 100 | ./PearBrowser-0.7.1-linux-x64.AppImage | |
| 101 | 101 | ``` |
| 102 | 102 | |
| 103 | 103 | ## Recovery Fallback |
| … | … | @@ -0,0 +1,31 @@ |
| 1 | # PearBrowser Desktop v0.7.1 | |
| 2 | ||
| 3 | PearBrowser `v0.7.1` is a corrective transport release for the desktop browser. | |
| 4 | It fixes the cluster of `RPC timeout` messages that could appear in Settings | |
| 5 | after a renderer reload or live-update handoff even though the identity, | |
| 6 | profile, permissions, and relay handlers were healthy. | |
| 7 | ||
| 8 | What changed: | |
| 9 | ||
| 10 | - Pending RPC calls fail immediately when the local renderer socket closes, | |
| 11 | instead of waiting 30 seconds and blaming an unrelated command number. | |
| 12 | - The per-launch authenticated renderer automatically reconnects during a | |
| 13 | bounded eight-second backend grace period. | |
| 14 | - Backend events are buffered across that brief handoff and the shell displays | |
| 15 | an explicit reconnect/resume screen. | |
| 16 | - Failed initial port candidates are destroyed so a late socket cannot claim | |
| 17 | the browser's single renderer slot. | |
| 18 | - If automatic recovery is impossible, PearBrowser gives a clear full-relaunch | |
| 19 | instruction without asking users to delete their profile or app storage. | |
| 20 | ||
| 21 | The full desktop suite passes `681/681`. A controlled Pear renderer reload also | |
| 22 | completed the authenticated reconnect path end to end; the four originally | |
| 23 | reported Settings calls then answered in `1–2 ms`. | |
| 24 | ||
| 25 | All browser protection and extension features from `v0.7.0` remain included: | |
| 26 | P2P Content Shield lists, capability-gated Pear Plugins, offline list restore, | |
| 27 | and the one-click P2P plugin catalogue. | |
| 28 | ||
| 29 | The stable Pear address continues to hot-sync the current app: | |
| 30 | ||
| 31 | `pear://tco5k7h38uoxatedp1wongdbhjxow1x7jiwm3t1i9cujbebhsbty` |
| … | … | @@ -0,0 +1,53 @@ |
| 1 | # PearBrowser Desktop v0.7.1 — release runbook | |
| 2 | ||
| 3 | Date: 2026-07-16 | |
| 4 | Tag: `v0.7.1` | |
| 5 | Repo: `bigdestiny2/pearbrowser-desktop` | |
| 6 | Production link: `pear://tco5k7h38uoxatedp1wongdbhjxow1x7jiwm3t1i9cujbebhsbty` | |
| 7 | Catalogue key: `f5fb7500bccd60a976d2b1d24246108f4444a210b9ca591533114dffc089934d` | |
| 8 | Website Hyperdrive key: `03f0060a35451cfb6b68ad1dda1b8474ebb43fd9100071ccf7d67679a83ebb4f` | |
| 9 | ||
| 10 | This corrective release is complete only when the source, stable Pear bytes, | |
| 11 | live catalogue, GitHub release/assets, `pearbrowser.com`, and website | |
| 12 | Hyperdrive mirror all advertise `v0.7.1` and pass their fresh-reader checks. | |
| 13 | ||
| 14 | ## Fix anchors | |
| 15 | ||
| 16 | - Renderer RPC rejects all pending calls immediately on socket close/error. | |
| 17 | - The authenticated renderer retries with bounded backoff while the backend | |
| 18 | preserves its Corestore and event buffer for eight seconds. | |
| 19 | - The reconnect screen remounts the app only after the RPC handshake returns. | |
| 20 | - Failed initial WebSocket candidates are destroyed before scanning the next | |
| 21 | RPC port. | |
| 22 | ||
| 23 | ## Verified candidate evidence | |
| 24 | ||
| 25 | - Focused transport and packaging tests: `64/64`. | |
| 26 | - Complete desktop suite: `681/681`. | |
| 27 | - Runtime smoke: DHT connected, proxy ready, 21 HiveRelay connections. | |
| 28 | - Controlled renderer reload: replacement backend/port handoff observed, | |
| 29 | followed by `renderer reconnected within grace period`. | |
| 30 | - Post-reload RPCs: commands `31`, `80`, `83`, and `40` all passed in `1–2 ms`. | |
| 31 | ||
| 32 | ## Coordinated publication order | |
| 33 | ||
| 34 | 1. Merge and tag the tested source candidate. | |
| 35 | 2. Publish the stable Pear production link with `./scripts/release-prod.sh`. | |
| 36 | 3. Publish catalogue version `9` with PearBrowser `0.7.1` and verify it from a | |
| 37 | fresh reader. | |
| 38 | 4. Create the GitHub `v0.7.1` release from | |
| 39 | `docs/RELEASE_NOTES_v0.7.1.md`, run package-proof native builds, and verify | |
| 40 | every attached asset against its SHA-256 sidecar. | |
| 41 | 5. Update/deploy `pearbrowser.com`, then publish and fresh-read the tracked-only | |
| 42 | website Hyperdrive mirror. | |
| 43 | 6. Record exact source refs, drive lengths, workflow URLs, and website evidence | |
| 44 | below. | |
| 45 | ||
| 46 | ## Final record | |
| 47 | ||
| 48 | - Source merge/tag: pending | |
| 49 | - Stable Pear length: pending (from `78006`) | |
| 50 | - Live catalogue length/version: pending (from length `307`, version `8`) | |
| 51 | - GitHub release/workflow: pending | |
| 52 | - Website deploy: pending | |
| 53 | - Website Hyperdrive length: pending (from `126`) |
| … | … | @@ -25,6 +25,7 @@ const { authorizeRpcWebSocket, DiagnosticRpcRouter } = rpcWebSocketAuth |
| 25 | 25 | // grabs the next port instead of failing with EADDRINUSE. |
| 26 | 26 | const RPC_PORT_BASE = 9876 |
| 27 | 27 | const RPC_PORT_COUNT = 5 |
| 28 | const RENDERER_RECONNECT_GRACE_MS = 8000 | |
| 28 | 29 | const rpcSessionToken = String(Pear.config?.startId || '') |
| 29 | 30 | const diagnosticToken = String(env.PEARBROWSER_RPC_DIAGNOSTIC_TOKEN || '') |
| 30 | 31 | if (!rpcSessionToken) throw new Error('Pear runtime did not provide a per-launch RPC session token') |
| … | … | @@ -105,6 +106,7 @@ try { |
| 105 | 106 | // Events emitted by the backend before the renderer connects are |
| 106 | 107 | // buffered here so nothing boot-time is missed. |
| 107 | 108 | let client = null |
| 109 | let rendererDisconnectTimer = null | |
| 108 | 110 | const diagnostics = new Set() |
| 109 | 111 | const eventBuffer = [] |
| 110 | 112 | const diagnosticRouter = new DiagnosticRpcRouter({ |
| … | … | @@ -183,6 +185,11 @@ const onSocket = (socket, req) => { |
| 183 | 185 | console.log('[rpc] rejecting extra WS connection') |
| 184 | 186 | return socket.end() |
| 185 | 187 | } |
| 188 | if (rendererDisconnectTimer) { | |
| 189 | clearTimeout(rendererDisconnectTimer) | |
| 190 | rendererDisconnectTimer = null | |
| 191 | console.log('[rpc] renderer reconnected within grace period') | |
| 192 | } | |
| 186 | 193 | console.log('[rpc] renderer connected') |
| 187 | 194 | client = socket |
| 188 | 195 | |
| … | … | @@ -208,19 +215,28 @@ const onSocket = (socket, req) => { |
| 208 | 215 | socket.on('data', (data) => backendPipe.write(data)) |
| 209 | 216 | socket.on('close', () => { |
| 210 | 217 | console.log('[rpc] renderer disconnected') |
| 211 | if (client === socket) client = null | |
| 212 | // The WS socket closing is the most reliable signal that the | |
| 213 | // user closed the window. Pipe events from runtime.start and | |
| 214 | // Pear.teardown proved unreliable in practice. Tear down the | |
| 215 | // main process here so the next launch doesn't see a zombie. | |
| 216 | teardown('renderer-ws-close') | |
| 218 | detachRenderer(socket) | |
| 217 | 219 | }) |
| 218 | 220 | socket.on('error', (err) => { |
| 219 | 221 | console.error('[rpc] socket error:', err.message) |
| 220 | if (client === socket) client = null | |
| 222 | detachRenderer(socket) | |
| 221 | 223 | }) |
| 222 | 224 | } |
| 223 | 225 | |
| 226 | function detachRenderer (socket) { | |
| 227 | if (client !== socket) return | |
| 228 | client = null | |
| 229 | if (rendererDisconnectTimer) clearTimeout(rendererDisconnectTimer) | |
| 230 | // A renderer reload or brief localhost socket interruption can reconnect | |
| 231 | // with the same per-launch token. Preserve the backend and buffered events | |
| 232 | // briefly; a genuine window close still tears down before it can become a | |
| 233 | // zombie that holds the Corestore lock or RPC port. | |
| 234 | rendererDisconnectTimer = setTimeout(() => { | |
| 235 | rendererDisconnectTimer = null | |
| 236 | if (!client) teardown('renderer-ws-close') | |
| 237 | }, RENDERER_RECONNECT_GRACE_MS) | |
| 238 | } | |
| 239 | ||
| 224 | 240 | let rpcServer = null |
| 225 | 241 | let rpcPort = null |
| 226 | 242 | function listenRpcServer (port) { |
| … | … | @@ -293,6 +309,8 @@ function teardown (reason) { |
| 293 | 309 | if (tornDown) return |
| 294 | 310 | tornDown = true |
| 295 | 311 | console.log('[teardown] triggered by', reason) |
| 312 | if (rendererDisconnectTimer) clearTimeout(rendererDisconnectTimer) | |
| 313 | rendererDisconnectTimer = null | |
| 296 | 314 | try { rpcServer?.close() } catch {} |
| 297 | 315 | try { client?.end?.() } catch {} |
| 298 | 316 | try { backendPipe.end?.() } catch {} |
| … | … | @@ -1,12 +1,12 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "pearbrowser-desktop", |
| 3 | "version": "0.7.0", | |
| 3 | "version": "0.7.1", | |
| 4 | 4 | "lockfileVersion": 3, |
| 5 | 5 | "requires": true, |
| 6 | 6 | "packages": { |
| 7 | 7 | "": { |
| 8 | 8 | "name": "pearbrowser-desktop", |
| 9 | "version": "0.7.0", | |
| 9 | "version": "0.7.1", | |
| 10 | 10 | "hasInstallScript": true, |
| 11 | 11 | "license": "Apache-2.0 AND MIT", |
| 12 | 12 | "dependencies": { |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "pearbrowser-desktop", |
| 3 | "version": "0.7.0", | |
| 3 | "version": "0.7.1", | |
| 4 | 4 | "description": "P2P browser + decentralized app store + site builder, desktop Pear fork of PearBrowser (jj10133/Vinjari family)", |
| 5 | 5 | "license": "Apache-2.0 AND MIT", |
| 6 | 6 | "type": "module", |
| … | … | @@ -2185,6 +2185,10 @@ test('runtime smoke is authenticated, status-only by default, and does not becom |
| 2185 | 2185 | assert.match(mainEntry, /diagnostics\.add\(socket\)/) |
| 2186 | 2186 | assert.match(mainEntry, /diagnostics\.delete\(socket\)/) |
| 2187 | 2187 | assert.match(mainEntry, /teardown\('renderer-ws-close'\)/) |
| 2188 | assert.match(mainEntry, /RENDERER_RECONNECT_GRACE_MS/) | |
| 2189 | assert.match(mainEntry, /renderer reconnected within grace period/) | |
| 2190 | assert.match(bootEntry, /enableReconnect/) | |
| 2191 | assert.match(bootEntry, /reconnect-failed/) | |
| 2188 | 2192 | assert.doesNotMatch(mainEntry.match(/const onDiagnosticSocket[\s\S]*?\n}\n\nconst onSocket/)?.[0] || '', /teardown\(/) |
| 2189 | 2193 | }) |
| 2190 | 2194 |
| … | … | @@ -0,0 +1,127 @@ |
| 1 | import test from 'node:test' | |
| 2 | import assert from 'node:assert/strict' | |
| 3 | import { EventEmitter } from 'node:events' | |
| 4 | import { RpcClient } from '../ui/rpc-client.js' | |
| 5 | import { WsPipe } from '../ui/boot.js' | |
| 6 | ||
| 7 | class MockPipe extends EventEmitter { | |
| 8 | constructor ({ connected = true, writable = true } = {}) { | |
| 9 | super() | |
| 10 | this.connected = connected | |
| 11 | this.writable = writable | |
| 12 | this.frames = [] | |
| 13 | } | |
| 14 | ||
| 15 | write (frame) { | |
| 16 | if (!this.writable) return false | |
| 17 | this.frames.push(frame) | |
| 18 | return true | |
| 19 | } | |
| 20 | } | |
| 21 | ||
| 22 | test('renderer RPC rejects pending requests immediately when the pipe closes', async () => { | |
| 23 | const pipe = new MockPipe() | |
| 24 | const rpc = new RpcClient(pipe) | |
| 25 | const pending = rpc.request(31, {}, 10_000) | |
| 26 | ||
| 27 | pipe.emit('close') | |
| 28 | ||
| 29 | await assert.rejects(pending, /RPC connection closed/) | |
| 30 | assert.equal(rpc._pending.size, 0) | |
| 31 | }) | |
| 32 | ||
| 33 | test('renderer RPC rejects new requests while the backend is disconnected', async () => { | |
| 34 | const pipe = new MockPipe({ connected: false }) | |
| 35 | const rpc = new RpcClient(pipe) | |
| 36 | ||
| 37 | await assert.rejects(rpc.request(80), /RPC unavailable: 80 .*reconnecting/) | |
| 38 | assert.equal(pipe.frames.length, 0) | |
| 39 | }) | |
| 40 | ||
| 41 | test('renderer RPC resumes requests after the pipe reconnects', async () => { | |
| 42 | const pipe = new MockPipe({ connected: false }) | |
| 43 | const rpc = new RpcClient(pipe) | |
| 44 | ||
| 45 | pipe.connected = true | |
| 46 | pipe.emit('open') | |
| 47 | const pending = rpc.request(40, {}, 1_000) | |
| 48 | assert.equal(pipe.frames.length, 1) | |
| 49 | ||
| 50 | const request = JSON.parse(pipe.frames[0].slice(8)) | |
| 51 | pipe.emit('data', frame({ id: request.id, result: { configured: true } })) | |
| 52 | ||
| 53 | assert.deepEqual(await pending, { configured: true }) | |
| 54 | }) | |
| 55 | ||
| 56 | test('renderer RPC rejects a request when the pipe is not writable', async () => { | |
| 57 | const pipe = new MockPipe({ writable: false }) | |
| 58 | const rpc = new RpcClient(pipe) | |
| 59 | ||
| 60 | await assert.rejects(rpc.request(83), /RPC connection is not writable/) | |
| 61 | assert.equal(rpc._pending.size, 0) | |
| 62 | }) | |
| 63 | ||
| 64 | test('renderer WebSocket reconnects and becomes writable again', async (t) => { | |
| 65 | const previousWebSocket = globalThis.WebSocket | |
| 66 | globalThis.WebSocket = FakeWebSocket | |
| 67 | FakeWebSocket.instances.length = 0 | |
| 68 | t.after(() => { globalThis.WebSocket = previousWebSocket }) | |
| 69 | ||
| 70 | const pipe = new WsPipe('ws://127.0.0.1:9876/?session=test', { | |
| 71 | reconnectBaseMs: 1, | |
| 72 | reconnectMaxMs: 1, | |
| 73 | maxReconnectAttempts: 2 | |
| 74 | }) | |
| 75 | t.after(() => pipe.destroy()) | |
| 76 | ||
| 77 | const first = FakeWebSocket.instances[0] | |
| 78 | first.open() | |
| 79 | pipe.enableReconnect() | |
| 80 | assert.equal(pipe.connected, true) | |
| 81 | ||
| 82 | first.disconnect() | |
| 83 | assert.equal(pipe.connected, false) | |
| 84 | await new Promise(resolve => setTimeout(resolve, 5)) | |
| 85 | ||
| 86 | const second = FakeWebSocket.instances[1] | |
| 87 | assert.ok(second, 'a replacement WebSocket should be created') | |
| 88 | second.open() | |
| 89 | assert.equal(pipe.connected, true) | |
| 90 | assert.equal(pipe.write('frame'), true) | |
| 91 | assert.deepEqual(second.sent, ['frame']) | |
| 92 | }) | |
| 93 | ||
| 94 | function frame (message) { | |
| 95 | const json = JSON.stringify(message) | |
| 96 | return json.length.toString(16).padStart(8, '0') + json | |
| 97 | } | |
| 98 | ||
| 99 | class FakeWebSocket extends EventTarget { | |
| 100 | static instances = [] | |
| 101 | ||
| 102 | constructor (url) { | |
| 103 | super() | |
| 104 | this.url = url | |
| 105 | this.binaryType = '' | |
| 106 | this.sent = [] | |
| 107 | this.closed = false | |
| 108 | FakeWebSocket.instances.push(this) | |
| 109 | } | |
| 110 | ||
| 111 | open () { | |
| 112 | this.dispatchEvent(new Event('open')) | |
| 113 | } | |
| 114 | ||
| 115 | disconnect () { | |
| 116 | this.dispatchEvent(new Event('close')) | |
| 117 | } | |
| 118 | ||
| 119 | send (frame) { | |
| 120 | if (this.closed) throw new Error('socket closed') | |
| 121 | this.sent.push(frame) | |
| 122 | } | |
| 123 | ||
| 124 | close () { | |
| 125 | this.closed = true | |
| 126 | } | |
| 127 | } |
| … | … | @@ -179,49 +179,125 @@ const C = { |
| 179 | 179 | EVT_ASK_BROWSER_STREAM: 111 |
| 180 | 180 | } |
| 181 | 181 | |
| 182 | class WsPipe { | |
| 183 | constructor (url) { | |
| 184 | this._listeners = { data: [], close: [], error: [], open: [] } | |
| 182 | export class WsPipe { | |
| 183 | constructor (url, opts = {}) { | |
| 184 | this._listeners = { data: [], close: [], error: [], open: [], reconnecting: [], 'reconnect-failed': [] } | |
| 185 | this._url = url | |
| 185 | 186 | this._connected = false |
| 186 | this._outgoing = [] | |
| 187 | console.log('[ws] connecting to', url) | |
| 188 | this._ws = new WebSocket(url) | |
| 189 | this._ws.binaryType = 'arraybuffer' | |
| 187 | this._connecting = false | |
| 188 | this._destroyed = false | |
| 189 | this._reconnectEnabled = false | |
| 190 | this._reconnectTimer = null | |
| 191 | this._reconnectAttempt = 0 | |
| 192 | this._maxReconnectAttempts = Number.isInteger(opts.maxReconnectAttempts) ? opts.maxReconnectAttempts : 8 | |
| 193 | this._reconnectBaseMs = Number.isFinite(opts.reconnectBaseMs) ? opts.reconnectBaseMs : 100 | |
| 194 | this._reconnectMaxMs = Number.isFinite(opts.reconnectMaxMs) ? opts.reconnectMaxMs : 1000 | |
| 195 | this._failedSocket = null | |
| 196 | this._ws = null | |
| 197 | this._connect() | |
| 198 | } | |
| 199 | ||
| 200 | get connected () { | |
| 201 | return this._connected | |
| 202 | } | |
| 203 | ||
| 204 | enableReconnect () { | |
| 205 | if (this._destroyed) return | |
| 206 | this._reconnectEnabled = true | |
| 207 | if (!this._connected && !this._connecting) this._scheduleReconnect() | |
| 208 | } | |
| 209 | ||
| 210 | destroy () { | |
| 211 | this._destroyed = true | |
| 212 | this._reconnectEnabled = false | |
| 213 | this._connected = false | |
| 214 | this._connecting = false | |
| 215 | if (this._reconnectTimer) clearTimeout(this._reconnectTimer) | |
| 216 | this._reconnectTimer = null | |
| 217 | const socket = this._ws | |
| 218 | this._ws = null | |
| 219 | try { socket?.close() } catch {} | |
| 220 | } | |
| 221 | ||
| 222 | _emit (event, detail) { | |
| 223 | for (const fn of this._listeners[event] || []) fn(detail) | |
| 224 | } | |
| 225 | ||
| 226 | _connect () { | |
| 227 | if (this._destroyed || this._connecting || this._connected) return | |
| 228 | this._connecting = true | |
| 229 | console.log('[ws] connecting to', this._url) | |
| 230 | const socket = new WebSocket(this._url) | |
| 231 | this._ws = socket | |
| 232 | this._failedSocket = null | |
| 233 | socket.binaryType = 'arraybuffer' | |
| 190 | 234 | |
| 191 | this._ws.addEventListener('open', () => { | |
| 235 | socket.addEventListener('open', () => { | |
| 236 | if (this._destroyed || socket !== this._ws) { | |
| 237 | try { socket.close() } catch {} | |
| 238 | return | |
| 239 | } | |
| 192 | 240 | console.log('[ws] open') |
| 241 | this._connecting = false | |
| 193 | 242 | this._connected = true |
| 194 | for (const fn of this._listeners.open) fn() | |
| 195 | for (const frame of this._outgoing) this._ws.send(frame) | |
| 196 | this._outgoing.length = 0 | |
| 243 | this._reconnectAttempt = 0 | |
| 244 | this._emit('open') | |
| 197 | 245 | }) |
| 198 | 246 | |
| 199 | this._ws.addEventListener('message', (e) => { | |
| 247 | socket.addEventListener('message', (e) => { | |
| 248 | if (socket !== this._ws || !this._connected) return | |
| 200 | 249 | const text = typeof e.data === 'string' |
| 201 | 250 | ? e.data |
| 202 | 251 | : new TextDecoder().decode(e.data) |
| 203 | for (const fn of this._listeners.data) fn(text) | |
| 252 | this._emit('data', text) | |
| 204 | 253 | }) |
| 205 | 254 | |
| 206 | this._ws.addEventListener('close', (e) => { | |
| 255 | socket.addEventListener('close', (e) => { | |
| 207 | 256 | console.log('[ws] close', e.code, e.reason) |
| 208 | for (const fn of this._listeners.close) fn() | |
| 257 | this._handleDisconnect(socket) | |
| 209 | 258 | }) |
| 210 | 259 | |
| 211 | this._ws.addEventListener('error', (e) => { | |
| 260 | socket.addEventListener('error', (e) => { | |
| 212 | 261 | console.error('[ws] error', e) |
| 213 | for (const fn of this._listeners.error) fn(e) | |
| 262 | this._handleDisconnect(socket, e) | |
| 263 | try { socket.close() } catch {} | |
| 214 | 264 | }) |
| 215 | 265 | } |
| 216 | 266 | |
| 267 | _handleDisconnect (socket, error = null) { | |
| 268 | if (this._destroyed || socket !== this._ws || this._failedSocket === socket) return | |
| 269 | this._failedSocket = socket | |
| 270 | this._connected = false | |
| 271 | this._connecting = false | |
| 272 | if (error) this._emit('error', error) | |
| 273 | this._emit('close') | |
| 274 | this._scheduleReconnect() | |
| 275 | } | |
| 276 | ||
| 277 | _scheduleReconnect () { | |
| 278 | if (!this._reconnectEnabled || this._destroyed || this._connected || this._connecting || this._reconnectTimer) return | |
| 279 | if (this._reconnectAttempt >= this._maxReconnectAttempts) { | |
| 280 | this._emit('reconnect-failed', { attempts: this._reconnectAttempt }) | |
| 281 | return | |
| 282 | } | |
| 283 | const attempt = ++this._reconnectAttempt | |
| 284 | const delay = Math.min(this._reconnectBaseMs * (2 ** (attempt - 1)), this._reconnectMaxMs) | |
| 285 | this._emit('reconnecting', { attempt, delay }) | |
| 286 | this._reconnectTimer = setTimeout(() => { | |
| 287 | this._reconnectTimer = null | |
| 288 | this._connect() | |
| 289 | }, delay) | |
| 290 | } | |
| 291 | ||
| 217 | 292 | on (event, fn) { |
| 218 | 293 | if (this._listeners[event]) this._listeners[event].push(fn) |
| 219 | 294 | return this |
| 220 | 295 | } |
| 221 | 296 | |
| 222 | 297 | write (frame) { |
| 223 | if (this._connected) this._ws.send(frame) | |
| 224 | else this._outgoing.push(frame) | |
| 298 | if (!this._connected || !this._ws) throw new Error('WebSocket RPC connection is not open') | |
| 299 | this._ws.send(frame) | |
| 300 | return true | |
| 225 | 301 | } |
| 226 | 302 | } |
| 227 | 303 | |
| … | … | @@ -301,10 +377,22 @@ function tryConnect (url, timeoutMs) { |
| 301 | 377 | return probeBackend(url, timeoutMs).then(() => new Promise((resolve, reject) => { |
| 302 | 378 | const pipe = new WsPipe(url) |
| 303 | 379 | let settled = false |
| 304 | const t = setTimeout(() => { if (!settled) { settled = true; reject(new Error('timeout')) } }, timeoutMs) | |
| 305 | pipe.on('open', () => { if (!settled) { settled = true; clearTimeout(t); resolve(pipe) } }) | |
| 306 | pipe.on('error', () => { if (!settled) { settled = true; clearTimeout(t); reject(new Error('ws error')) } }) | |
| 307 | pipe.on('close', () => { if (!settled) { settled = true; clearTimeout(t); reject(new Error('ws closed')) } }) | |
| 380 | const finish = (err = null) => { | |
| 381 | if (settled) return | |
| 382 | settled = true | |
| 383 | clearTimeout(t) | |
| 384 | if (err) { | |
| 385 | pipe.destroy() | |
| 386 | reject(err) | |
| 387 | } else { | |
| 388 | pipe.enableReconnect() | |
| 389 | resolve(pipe) | |
| 390 | } | |
| 391 | } | |
| 392 | const t = setTimeout(() => finish(new Error('timeout')), timeoutMs) | |
| 393 | pipe.on('open', () => finish()) | |
| 394 | pipe.on('error', () => finish(new Error('ws error'))) | |
| 395 | pipe.on('close', () => finish(new Error('ws closed'))) | |
| 308 | 396 | })) |
| 309 | 397 | } |
| 310 | 398 |
| … | … | @@ -54,25 +54,42 @@ try { |
| 54 | 54 | failed=${true} />`) |
| 55 | 55 | }) |
| 56 | 56 | |
| 57 | // Only steal the splash screen for pre-mount failures. After the | |
| 58 | // App mounts, the status pill at the bottom of the real UI handles | |
| 59 | // disconnect/reconnect state without yanking the user to a splash. | |
| 57 | // A renderer reload or transient local WebSocket failure should not leave | |
| 58 | // the mounted app issuing requests into a dead pipe. Show an explicit | |
| 59 | // reconnect state and remount once the authenticated socket is restored. | |
| 60 | 60 | let mounted = false |
| 61 | const mountApp = () => { | |
| 62 | if (bootFailedShown || mounted || !pipe.connected) return | |
| 63 | mounted = true | |
| 64 | root.render(html`<${App} rpc=${rpc} C=${C} storagePath=${storagePath} />`) | |
| 65 | } | |
| 61 | 66 | pipe.on('open', () => { |
| 62 | if (!mounted && !bootFailedShown) root.render(html`<${Splash} message="Handshake OK · waiting for DHT…" />`) | |
| 67 | if (bootFailedShown) return | |
| 68 | root.render(html`<${Splash} message="Handshake restored · resuming…" />`) | |
| 69 | setTimeout(mountApp, 50) | |
| 63 | 70 | }) |
| 64 | pipe.on('error', () => { | |
| 65 | if (!mounted && !bootFailedShown) root.render(html`<${Splash} message="Backend unreachable" detail="ws://127.0.0.1:9876 — is the main process running?" failed=${true} />`) | |
| 71 | pipe.on('error', (err) => { | |
| 72 | console.error('Backend RPC connection error:', err) | |
| 66 | 73 | }) |
| 67 | 74 | pipe.on('close', () => { |
| 68 | if (!mounted && !bootFailedShown) root.render(html`<${Splash} message="Backend disconnected" detail="The WebSocket closed unexpectedly. Restart the app." failed=${true} />`) | |
| 75 | if (bootFailedShown) return | |
| 76 | mounted = false | |
| 77 | root.render(html`<${Splash} message="Backend connection lost · reconnecting…" />`) | |
| 78 | }) | |
| 79 | pipe.on('reconnecting', ({ attempt } = {}) => { | |
| 80 | if (bootFailedShown) return | |
| 81 | root.render(html`<${Splash} message=${`Reconnecting to backend${attempt ? ` · attempt ${attempt}` : ''}…`} />`) | |
| 82 | }) | |
| 83 | pipe.on('reconnect-failed', () => { | |
| 84 | if (bootFailedShown) return | |
| 85 | mounted = false | |
| 86 | root.render(html`<${Splash} | |
| 87 | message="Backend disconnected" | |
| 88 | detail="Automatic reconnect failed. Fully quit and relaunch PearBrowser; your profile and application storage are safe." | |
| 89 | failed=${true} />`) | |
| 69 | 90 | }) |
| 70 | 91 | |
| 71 | setTimeout(() => { | |
| 72 | if (bootFailedShown) return // never mount the app on a dead backend | |
| 73 | mounted = true | |
| 74 | root.render(html`<${App} rpc=${rpc} C=${C} storagePath=${storagePath} />`) | |
| 75 | }, 250) | |
| 92 | setTimeout(mountApp, 250) | |
| 76 | 93 | } catch (err) { |
| 77 | 94 | console.error('Boot failed:', err) |
| 78 | 95 | root.render(html`<${Splash} message="Boot failed" detail=${err.stack || err.message} failed=${true} />`) |
| … | … | @@ -13,16 +13,30 @@ export class RpcClient extends EventTarget { |
| 13 | 13 | this._nextId = 1 |
| 14 | 14 | this._pending = new Map() |
| 15 | 15 | this._buffer = '' |
| 16 | this._connected = pipe.connected !== false | |
| 16 | 17 | |
| 17 | 18 | pipe.on('data', (chunk) => this._onData(chunk)) |
| 18 | pipe.on('close', () => this.dispatchEvent(new CustomEvent('close'))) | |
| 19 | pipe.on('error', (err) => this.dispatchEvent(new CustomEvent('error', { detail: err }))) | |
| 19 | pipe.on('open', () => { | |
| 20 | this._connected = true | |
| 21 | this.dispatchEvent(new CustomEvent('open')) | |
| 22 | }) | |
| 23 | pipe.on('close', () => { | |
| 24 | this._disconnect('RPC connection closed') | |
| 25 | this.dispatchEvent(new CustomEvent('close')) | |
| 26 | }) | |
| 27 | pipe.on('error', (err) => { | |
| 28 | this._disconnect('RPC connection failed') | |
| 29 | this.dispatchEvent(new CustomEvent('error', { detail: err })) | |
| 30 | }) | |
| 20 | 31 | } |
| 21 | 32 | |
| 22 | 33 | request (cmd, data = {}, timeout = 30000) { |
| 23 | 34 | if (cmd === undefined || cmd === null) { |
| 24 | 35 | return Promise.reject(new Error('RPC command is missing. Renderer constants are out of sync with backend/constants.js.')) |
| 25 | 36 | } |
| 37 | if (!this._connected) { | |
| 38 | return Promise.reject(new Error(`RPC unavailable: ${cmd} (backend disconnected; reconnecting)`)) | |
| 39 | } | |
| 26 | 40 | |
| 27 | 41 | return new Promise((resolve, reject) => { |
| 28 | 42 | const id = this._nextId++ |
| … | … | @@ -33,7 +47,13 @@ export class RpcClient extends EventTarget { |
| 33 | 47 | } |
| 34 | 48 | }, timeout) |
| 35 | 49 | this._pending.set(id, { resolve, reject, timer }) |
| 36 | this._send({ id, cmd, data }) | |
| 50 | try { | |
| 51 | this._send({ id, cmd, data }) | |
| 52 | } catch (err) { | |
| 53 | clearTimeout(timer) | |
| 54 | this._pending.delete(id) | |
| 55 | reject(err) | |
| 56 | } | |
| 37 | 57 | }) |
| 38 | 58 | } |
| 39 | 59 | |
| … | … | @@ -45,7 +65,18 @@ export class RpcClient extends EventTarget { |
| 45 | 65 | _send (msg) { |
| 46 | 66 | const json = JSON.stringify(msg) |
| 47 | 67 | const frame = json.length.toString(16).padStart(8, '0') + json |
| 48 | this._pipe.write(frame) | |
| 68 | if (this._pipe.write(frame) === false) { | |
| 69 | throw new Error('RPC connection is not writable') | |
| 70 | } | |
| 71 | } | |
| 72 | ||
| 73 | _disconnect (message) { | |
| 74 | this._connected = false | |
| 75 | for (const pending of this._pending.values()) { | |
| 76 | clearTimeout(pending.timer) | |
| 77 | pending.reject(new Error(message)) | |
| 78 | } | |
| 79 | this._pending.clear() | |
| 49 | 80 | } |
| 50 | 81 | |
| 51 | 82 | _onData (chunk) { |