Files
paseo/packages/app/gpui/README.md
T
Mohamed Boudra 291c8ad1a8 fix(gpui): stabilize macOS packaging, connections and appearance
Release terminal WebSocket clients on the JS thread so daemon retries do not exhaust descriptors or join their own IO thread. Preserve Cargo symbols and seal ad-hoc macOS bundles with a library-loading policy that works with hardened runtime.

Use rounded border decoration without changing Fabric child layout, and route desktop hover through pointer events and Pressable render props. Include native regressions and reproducible package verification.
2026-11-08 04:40:38 +01:00

275 lines
19 KiB
Markdown

# Experimental GPUI client
Run Paseo's existing React Native app in a native GPUI window on Linux or macOS. This is
a community starting point: the real workspace UI runs, but terminal/diff
renderers and several native services still need porting. The normal desktop
build remains Electron. The native app connects to a separately running daemon.
## Run it
Start from the repository's [development setup](../../../docs/development.md).
A fresh checkout needs the root dependencies and workspace build outputs:
```sh
npm ci
npm run build:server
npm run gpui:install
EXPO_PUBLIC_LOCAL_DAEMON=localhost:6768 npm run dev:gpui
```
Use an already-running compatible daemon and replace `localhost:6768` with its
endpoint. These GPUI commands never start or restart a daemon. The address is
inlined at bundle time. The app's normal connection/authentication rules apply.
You also need Rust stable through rustup, CMake, Ninja, Clang, Linux X11/Wayland
development libraries, and Vulkan. The tested Linux x86_64 host already had
most build tools; it additionally needed `libxkbcommon-x11-dev`. A clean-machine
prerequisite check is still needed. The first native build downloads dependencies
and builds Rust/C++; allow several minutes and several GB of disk.
`dev:gpui` bundles the app, autolinks native modules, builds, and opens the window.
Restart after editing. It uses an offline production JS bundle; Fast Refresh is
not wired into this launcher. `build:gpui` builds without opening a window.
`gpui:bundle -- --dev false` only writes the JS bundle, source map, and assets to
`.artifacts/gpui/`. Bundle validation checks that the real routes are present and
that no `react-native-webview` modules were included.
## Build a macOS test DMG
On an Apple Silicon Mac, install the dependencies above plus Xcode, CMake, and
Ninja. From the repository root, after `npm run gpui:install`:
```sh
gpui_repo_root="$PWD"
cd packages/app/gpui
MACOSX_DEPLOYMENT_TARGET=15.0 NODE_OPTIONS=--max-old-space-size=6144 CARGO_BUILD_JOBS=4 \
CARGO_TARGET_DIR="$gpui_repo_root/.artifacts/gpui/macos/target" \
PASEO_RENDERER=gpui EXPO_PUBLIC_LOCAL_DAEMON=localhost:6767 \
node node_modules/react-native/cli.js package-desktop \
--target app,dmg --out "$gpui_repo_root/.artifacts/gpui/macos/dist"
```
Use the React Native CLI entry point shown here; the standalone GPUI CLI rejects
`package-desktop`. Keep one absolute `CARGO_TARGET_DIR` for both compilation and
packaging because their default target directories differ. Metro needs more than
Node's default 2 GiB heap for this app.
Keep `MACOSX_DEPLOYMENT_TARGET` aligned with `desktop/desktop.json` when building
from source; it controls both Rust and CMake's macOS API availability.
The DMG requires macOS 15 or later, matching the bundled Hermes/JSI libraries.
It contains `Paseo GPUI.app` with ad-hoc signing and the library-validation
exception required for loading ad-hoc dylibs under hardened runtime. It is not
notarized or signed with Developer ID. Copy it out of the mounted DMG before testing. Its daemon
address is fixed at bundle time, and the installer does not include a daemon.
Native terminals and diff rendering remain unsupported.
Check the installed bundle before sharing it:
```sh
node packages/app/gpui/verify-macos-package.mjs "/Applications/Paseo GPUI.app"
```
This checks the sealed signature, ad-hoc library-loading entitlement, and the
minimum macOS version of each bundled dylib. A successful launch on a Mac with
SIP disabled does not test Team ID library validation; verify the signing policy
and use a Mac with SIP enabled for distribution launch acceptance.
Namespace GUI testing needs an active remote desktop session; SSH alone can leave
GPUI waiting for a display. Use Namespace's `devbox desktop` commands before
launching the app. Keep Cargo's generated debug-symbol symlink intact when
rebuilding; the packager copies its contents alongside the installer.
## Approach and renderer choice
```text
Existing Paseo RootApp + Expo Router + state/client/plugin code
|
React in Hermes
|
React Native Fabric
|
Rust GPUI native renderer
|
Linux GPU window
Existing Paseo client <-- WebSocket --> separately running Node daemon
```
The separate npm project is deliberate: the selected runtime uses RN 0.87.1,
while Paseo's mobile app uses RN 0.86.3. Metro redirects native dependencies to
that installation and resolves the rest from the app. This avoids changing the
mobile dependency baseline just to try another renderer. It also creates a
version-maintenance cost that needs testing before this becomes a supported target.
The choice was to preserve the existing app's RN component and library contracts:
| Candidate | Integration model | Consequence for this experiment |
| ----------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [Streetwriters react-native-gpui](https://github.com/streetwriters/react-native-gpui/tree/de11aa265e6fb36c80e8a30315bce6d34a9be7d6) | React Native Fabric, Hermes, and GPUI, with ports of screens, gestures, Reanimated/worklets, safe areas, and SVG | Chosen because it lets the existing RN app reach a real Linux window with targeted adapters. These ports are still experimental. |
| [natew/react-native-gpui](https://github.com/natew/react-native-gpui/tree/b2254543357f02fdd983d0d8a584d1d1bd93f5da) | Custom React reconciler with an RN-like component surface; its README targets macOS arm64/Metal | Useful implementation reference. Running this Linux app would also require platform work and compatibility with the RN libraries it imports. |
| [GPUIX](https://github.com/remorses/gpuix) | Custom React/Solid renderer, its own JSX primitives, and Node-API native bindings; supports Bun/Node execution | Also native GPUI, with interesting packaging and specialized surfaces. Adopting it would require replacing or adapting Paseo's RN component/library contracts. It remains an alternative worth testing, not a rejected renderer. |
| Existing Electron target | RN Web inside Chromium, with the desktop host bridge | Preserves the working desktop feature set, but does not answer the experiment's question of running the app through a native renderer. |
This is an integration choice, not a performance comparison. There are no
controlled CPU, memory, startup, or frame-time results against these alternatives.
## What belongs in the change
| Area | Why it is here |
| ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `vendor/react-native-gpui/` | Editable, pinned runtime source. This accounts for roughly 130,000 lines of the diff. GitHub marks it as vendored; inspect [UPSTREAM](../../../vendor/react-native-gpui/UPSTREAM) for provenance and the local patches. |
| npm and Cargo lockfiles | Reproduce the experiment's dependencies. Roughly 15,000 generated lines, collapsed on GitHub. |
| `desktop/`, `index.js`, `routes.js`, Metro/Babel config, root launcher | Register the full app, select the native runtime, compile native services, and bundle the actual route graph. The explicit route context prevents an apparently successful build with no app routes. |
| `adapters/` | Desktop implementations of the app-used APIs, plus explicit unavailable-capability implementations for mobile features. About 700 lines; these are limited ports, not complete replacements for Expo/Unistyles. |
| Shared app edits | One capability gate for alpha-mask shimmer. GPUI shows the logo and labels without that effect. Existing web/iOS/Android paths keep their behavior. |
| Native tests and this guide | Make the working slice reproducible and give contributors bounded follow-up work. |
Upstream examples, its tester/testing package, website, unused fonts/vector-icon
ports, starter navigation dependencies, sample icon, and unused nested Cargo config
are omitted. Build outputs, native binaries, downloaded SDKs, logs, screenshots,
and the exploratory size-measurement copy stay out of Git.
The retained runtime still includes its build machinery, platform backends,
headers, and tests. Arbitrarily deleting those to reduce line count makes an
upstream refresh harder. Vendoring can later be replaced by a pinned dependency
once the local patches are upstreamed and distribution is reproducible.
### Editing the runtime
Edit `vendor/react-native-gpui`, then run `npm run gpui:install` before rebuilding.
npm copies local packages rather than symlinking them; the install command
refreshes those copies even when their versions have not changed.
There are two local renderer changes:
- `node_view.rs`: a `pointerEvents="box-none"` view must not install its own hit
area. Before the patch, Paseo's empty portal overlay intercepted sidebar clicks.
- `StatusBar.desktop.js`: desktop windows do not have the mobile OS status bar.
GPUI remains a pinned Cargo git dependency. Upstream tooling downloads Hermes
and React Native prebuilt libraries. The snapshot is not an offline or fully
source-built distribution, and release packaging has not been verified.
## Working slice and limits
Verified on Linux x86_64 and macOS arm64: all 24 app routes in the bundle, connection to the
daemon, the real project/workspace sidebar, opening an existing workspace,
existing message rendering, and native composer typing/deletion. The native
regression test restores the draft and does not send a message. Rust tests cover
SHA-256/random-byte bounds and real SQLite parameter binding/transaction rollback.
The macOS build also passed draft restoration after restarting the native process.
The following are not feature-parity claims:
| Area | Current state |
| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Styling | Adapter evaluates existing Unistyles theme/style factories and breakpoints. No Nitro shadow-tree integration. Full theme/resize behavior needs QA. |
| Storage/crypto/localization | Native filesystem + SQLite + SHA-256 + OS randomness. Intl ships English locale data and IANA time zones. The Rust service contains Linux-specific OS access. |
| Clipboard/files | Text clipboard, native file dialogs, PNG/JPEG picking, and download/export adapters exist. End-to-end dialog/transfer coverage is still missing. |
| Voice/camera/push/power inhibition | Unavailable. Requests either report the missing capability or use the app's existing unavailable-capability implementation. |
| Image conversion/image clipboard | Unavailable. The picker accepts PNG/JPEG without conversion. |
| Mermaid/HTML/document previews | Mermaid and HTML show source. Document previews report unavailable. No embedded browser backend. |
| Terminals | The mobile view expects an Expo `PaseoTerminal` component. No GPUI implementation yet. |
| Diffs | The mobile surface uses React Native Skia. No GPUI drawing backend yet. |
| Plugins | The existing loader evaluates JS and injects host React/RN. No end-to-end plugin verification on GPUI yet. |
| Other platforms | Linux x86_64 and macOS arm64 have been exercised. Windows remains unverified. |
The initial stripped development executable was about 68 MiB, with about 10 MiB
of Hermes/JSI libraries and a 21 MiB JS bundle, approximately 100 MiB uncompressed.
That measurement excludes the daemon, is not an optimized release installer, and
does not establish memory usage or a size advantage over another renderer.
## Experiments to pick up
Each experiment should preserve the real app and demonstrate a user workflow.
1. **Native terminal.** Implement a GPUI component for the existing
[`PaseoTerminalView`](../modules/paseo-terminal/index.tsx) frame contract.
Keep the JS headless xterm emulator, transport, and frame generation initially.
Verify resize/replay, keyboard/IME/paste, scrollback/selection, and the existing
Unicode corpus. [Terminal rendering](../../../docs/terminal-rendering.md)
explains why shaping must preserve xterm's cell columns.
2. **Diff surface.** Keep `src/git/diff-document`'s model and review actions;
add GPUI text measurement and painting instead of the Skia surface. Test
unified/split views, wrapping, scrolling, selection, and inline reviews on
large real diffs. The [C++ layout experiment](../modules/paseo-diff-prototype/README.md)
is a separate candidate for shared geometry; it is not a finished renderer.
3. **Plugins.** Load a small plugin into an isolated daemon, open its real screen
or workspace panel, call a plugin RPC, and reload it. Verify subscriptions and
cleanup. Track unsupported services explicitly instead of adding fake APIs.
4. **Native services and styling.** Exercise dialogs, downloads, clipboard,
persistence/restart, theme switching, resizing, and focus. Replace the narrow
adapters with complete implementations only where the app needs them.
5. **Portability and packaging.** Build on clean Linux, then macOS/Windows. Replace
Linux-only randomness/timezone access, check font/shaping and IME behavior, and
verify relocatable libraries/assets. Measure a release build and a controlled
workflow before claiming a performance or size improvement.
6. **Bundled daemon, independently.** First run the unchanged daemon under Bun
in isolation. Verify PTY/native dependencies, terminal-worker subprocesses,
provider launches, and dynamic plugins. Then compare standalone/AOT packaging
and size. This branch neither ports the daemon nor embeds it in the UI process.
A Rust/GPUI terminal or diff painter can share desktop implementation across the
upstream renderer's supported OSes. Reusing it on mobile still requires mobile
bindings and graphics integration; choosing Rust or C++ alone does not provide that.
## Verification
Run the normal checks from the root. GPUI adapters are excluded from the mobile
TypeScript project; its typecheck alone does not validate this target.
```sh
npm run typecheck
npm run lint
npm run format:check
```
For the native UI regression, use a daemon with an existing workspace and build
with the driver. Run the native app on an isolated display when testing on a
machine in use:
```sh
EXPO_PUBLIC_LOCAL_DAEMON=localhost:6768 npm run build:gpui -- --features test-driver,visual-capture
RN_GPUI_TEST_DRIVER=1 RN_GPUI_TEST_DRIVER_TOKEN=local-test \
RN_GPUI_DEV_MODE=0 RN_GPUI_BUNDLE="$PWD/.artifacts/gpui/paseo.bundle" \
RN_GPUI_ASSETS_DIR="$PWD/.artifacts/gpui/assets/assets" \
xvfb-run -a -s '-screen 0 2560x1600x24' \
packages/app/gpui/desktop/target/debug/react-native-gpui-paseo-gpui
```
Use the printed `RN_GPUI_TEST_DRIVER_PORT` in another shell:
```sh
PASEO_GPUI_TEST_URL=ws://127.0.0.1:PORT PASEO_GPUI_TEST_TOKEN=local-test \
npx vitest run packages/app/gpui/native.test.ts --bail=1
```
Without a driver URL the test is skipped. Put diagnostic logs/captures in
`.artifacts/gpui/`; do not commit them or publish private workspace contents as QA.
For border and hover regressions, bundle the isolated scene from `packages/app/gpui`:
```sh
PASEO_RENDERER=gpui node node_modules/react-native/cli.js bundle --platform desktop \
--entry-file appearance-regression.fixture.js --config desktop/metro.config.js \
--bundle-output ../../../.artifacts/gpui/appearance.bundle --dev false --max-workers 1
```
Launch the driver-enabled executable with `RN_GPUI_BUNDLE` pointing at that bundle.
Run `native.test.ts` from the root with the driver URL/token above and
`PASEO_GPUI_TEST_SCENE=appearance`. It checks the rendered border pixels,
Pressable hover callbacks and render props, and hover across nested controls.
No daemon is needed for this scene.
On macOS, test WebSocket cleanup against a compiled native executable:
```sh
PASEO_GPUI_TEST_BINARY="/absolute/path/to/react-native-gpui-paseo-gpui" \
npx vitest run packages/app/gpui/websocket.native.test.ts --bail=1
```
This opens an isolated native window and uses a loopback peer for 128 rejected
handshakes, 128 server closes, and 128 client closes under a 256-descriptor limit.
It needs an active display and the GPUI npm dependencies, but no Paseo daemon.
Without the executable path it is skipped. Its bundle and logs stay in `.artifacts/gpui/`.