diff --git a/packages/app/gpui/README.md b/packages/app/gpui/README.md
index 858aba15e3..6b70af5e0e 100644
--- a/packages/app/gpui/README.md
+++ b/packages/app/gpui/README.md
@@ -1,9 +1,9 @@
# Experimental GPUI client
-Run Paseo's existing React Native app in a native GPUI window on Linux. This is
+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. No daemon changes or release installers are included.
+build remains Electron. The native app connects to a separately running daemon.
## Run it
@@ -34,6 +34,51 @@ not wired into this launcher. `build:gpui` builds without opening a window.
`.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
@@ -70,14 +115,14 @@ controlled CPU, memory, startup, or frame-time results against these alternative
## 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 two 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. |
+| 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
@@ -107,11 +152,12 @@ source-built distribution, and release packaging has not been verified.
## Working slice and limits
-Verified on Linux x86_64: all 24 app routes in the bundle, connection to the dev
+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:
@@ -126,7 +172,7 @@ The following are not feature-parity claims:
| 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 | The upstream runtime targets macOS/Windows too; this Paseo integration has only been exercised on Linux. |
+| 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.
@@ -200,3 +246,29 @@ 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/`.
diff --git a/packages/app/gpui/adapters/intl-timezone.js b/packages/app/gpui/adapters/intl-timezone.js
new file mode 100644
index 0000000000..8b4e7eac6c
--- /dev/null
+++ b/packages/app/gpui/adapters/intl-timezone.js
@@ -0,0 +1,5 @@
+export function setDefaultTimeZone(timeZone) {
+ // macOS uses the IANA alias GMT. FormatJS's timezone data omits that alias;
+ // normalize it at the polyfill boundary while preserving other system zones.
+ Intl.DateTimeFormat.__setDefaultTimeZone(timeZone.toUpperCase() === "GMT" ? "UTC" : timeZone);
+}
diff --git a/packages/app/gpui/adapters/intl-timezone.test.ts b/packages/app/gpui/adapters/intl-timezone.test.ts
new file mode 100644
index 0000000000..72a12ef366
--- /dev/null
+++ b/packages/app/gpui/adapters/intl-timezone.test.ts
@@ -0,0 +1,23 @@
+/* oxlint-disable import/no-unassigned-import -- Exercise the actual GPUI Intl implementation. */
+import "@formatjs/intl-datetimeformat/polyfill-force.js";
+import "@formatjs/intl-datetimeformat/locale-data/en.js";
+import "@formatjs/intl-datetimeformat/add-all-tz.js";
+import { afterEach, expect, test } from "vitest";
+import { setDefaultTimeZone } from "./intl-timezone";
+
+afterEach(() => setDefaultTimeZone("UTC"));
+
+test.each([
+ ["GMT", "UTC"],
+ ["gmt", "UTC"],
+ ["UTC", "UTC"],
+ ["Europe/Berlin", "Europe/Berlin"],
+ ["US/Pacific", "America/Los_Angeles"],
+])("initializes the system timezone %s as %s", (systemZone, expectedZone) => {
+ setDefaultTimeZone(systemZone);
+ expect(new Intl.DateTimeFormat("en").resolvedOptions().timeZone).toBe(expectedZone);
+});
+
+test("does not silently replace an invalid system timezone", () => {
+ expect(() => setDefaultTimeZone("Invalid/Zone")).toThrow(RangeError);
+});
diff --git a/packages/app/gpui/adapters/intl.js b/packages/app/gpui/adapters/intl.js
index 728cfe167a..4e0db26a76 100644
--- a/packages/app/gpui/adapters/intl.js
+++ b/packages/app/gpui/adapters/intl.js
@@ -13,4 +13,5 @@ import "@formatjs/intl-displaynames/polyfill-force.js";
import "@formatjs/intl-displaynames/locale-data/en.js";
import "@formatjs/intl-segmenter/polyfill-force.js";
import Desktop from "../desktop/src/NativePaseoDesktop";
-Intl.DateTimeFormat.__setDefaultTimeZone(Desktop.timezone());
+import { setDefaultTimeZone } from "./intl-timezone";
+setDefaultTimeZone(Desktop.timezone());
diff --git a/packages/app/gpui/appearance-regression.fixture.js b/packages/app/gpui/appearance-regression.fixture.js
new file mode 100644
index 0000000000..39794ddcc7
--- /dev/null
+++ b/packages/app/gpui/appearance-regression.fixture.js
@@ -0,0 +1,54 @@
+/* oxlint-disable react-perf/jsx-no-new-function-as-prop, react-perf/jsx-no-new-object-as-prop -- Minimal native regression scene. */
+import React, { useState } from "react";
+import { AppRegistry, Pressable, Text, View } from "react-native";
+
+function AppearanceRegression() {
+ const [buttonHovered, setHovered] = useState(false);
+ const [entered, setEntered] = useState(false);
+ return (
+
+
+ setHovered(true)}
+ onHoverOut={() => setHovered(false)}
+ style={{ width: 200, height: 60, backgroundColor: buttonHovered ? "#00ff00" : "#0000ff" }}
+ >
+ {buttonHovered ? "hover active" : "hover inactive"}
+
+ ({
+ width: 200,
+ height: 60,
+ backgroundColor: hovered ? "#00ff00" : "#0000ff",
+ })}
+ >
+ {({ hovered }) => {hovered ? "render hovered" : "render idle"}}
+
+ setEntered(true)}
+ onPointerLeave={() => setEntered(false)}
+ style={{ width: 200, height: 60, backgroundColor: entered ? "#00ff00" : "#0000ff" }}
+ >
+
+ {entered ? "row active" : "row inactive"}
+
+
+
+
+ );
+}
+
+AppRegistry.registerComponent("Paseo", () => AppearanceRegression);
diff --git a/packages/app/gpui/desktop/Cargo.toml b/packages/app/gpui/desktop/Cargo.toml
index 8569087855..2c66c6e31f 100644
--- a/packages/app/gpui/desktop/Cargo.toml
+++ b/packages/app/gpui/desktop/Cargo.toml
@@ -21,8 +21,8 @@ opt-level = 0
[profile.release]
debug = "line-tables-only"
-# Cargo strips inside the linker, before dsymutil, so package-desktop writes the
-# dSYM and then strips. Full LTO: 2 MB smaller for 1.4x the build time.
+# Cargo strips inside the linker, before dsymutil, so package-desktop preserves the
+# Cargo dSYM and then strips. Full LTO: 2 MB smaller for 1.4x the build time.
strip = "none"
split-debuginfo = "packed"
lto = "fat"
diff --git a/packages/app/gpui/desktop/desktop.json b/packages/app/gpui/desktop/desktop.json
index 6b2b6d1585..b92b1580de 100644
--- a/packages/app/gpui/desktop/desktop.json
+++ b/packages/app/gpui/desktop/desktop.json
@@ -1 +1,4 @@
-{ "identifier": "sh.paseo.gpui" }
+{
+ "identifier": "sh.paseo.gpui",
+ "bundle": { "macos": { "minimumSystemVersion": "15.0" } }
+}
diff --git a/packages/app/gpui/native.test.ts b/packages/app/gpui/native.test.ts
index ddf65fff27..e3ae4ec33a 100644
--- a/packages/app/gpui/native.test.ts
+++ b/packages/app/gpui/native.test.ts
@@ -1,9 +1,13 @@
import { afterAll, beforeAll, expect, test } from "vitest";
+import sharp from "sharp";
+import { mkdirSync, writeFileSync } from "node:fs";
+import path from "node:path";
// Runs against the actual Paseo bundle in the compiled GPUI app. Start with
// RN_GPUI_TEST_DRIVER=1 and pass its loopback URL and token to this test.
const endpoint = process.env.PASEO_GPUI_TEST_URL;
const token = process.env.PASEO_GPUI_TEST_TOKEN;
+const appearanceFixture = process.env.PASEO_GPUI_TEST_SCENE === "appearance";
let socket: WebSocket;
let nextId = 0;
interface Node {
@@ -62,7 +66,7 @@ beforeAll(async () => {
});
afterAll(() => socket?.close());
-test.skipIf(!endpoint)(
+test.skipIf(!endpoint || appearanceFixture)(
"real Paseo portal passes workspace clicks and the native composer edits text",
async () => {
const workspace = (await nodes()).find((node) =>
@@ -85,7 +89,7 @@ test.skipIf(!endpoint)(
const original = (await request<{ text: string }>("getProps", { tag: input.tag })).text;
try {
await request("click", { tag: input.tag });
- await request("press", { key: "ctrl-a" });
+ await request("press", { key: "secondary-a" });
await request("press", { key: "backspace" });
expect(
await request("type", { tag: input.tag, text: "GPUI native input regression" }),
@@ -94,10 +98,100 @@ test.skipIf(!endpoint)(
.poll(async () => (await request<{ text: string }>("getProps", { tag: input.tag })).text)
.toBe("GPUI native input regression");
} finally {
- await request("press", { key: "ctrl-a" });
+ await request("press", { key: "secondary-a" });
await request("press", { key: "backspace" });
if (original) await request("type", { tag: input.tag, text: original });
+ await expect
+ .poll(async () => (await request<{ text: string }>("getProps", { tag: input.tag })).text)
+ .toBe(original);
}
},
45_000,
);
+
+async function nodeWithId(testID: string) {
+ await expect.poll(async () => (await nodes()).some((node) => node.testID === testID)).toBe(true);
+ return (await nodes()).find((node) => node.testID === testID)!;
+}
+
+async function expectBackground(node: Node, color: number) {
+ await expect
+ .poll(
+ async () =>
+ (await request<{ backgroundColor: number }>("getProps", { tag: node.tag })).backgroundColor,
+ )
+ .toBe(color);
+}
+
+test.skipIf(!endpoint || !appearanceFixture)(
+ "Pressable render props receive hover state",
+ async () => {
+ const button = await nodeWithId("hover-render-prop");
+ const outside = await nodeWithId("outside-hover");
+ await request("hover", { tag: button.tag });
+ await expectBackground(button, 0xff00ff00);
+ await expect
+ .poll(async () => (await nodes()).some((node) => node.text === "render hovered"))
+ .toBe(true);
+ await request("hover", { tag: outside.tag });
+ await expectBackground(button, 0xff0000ff);
+ await expect
+ .poll(async () => (await nodes()).some((node) => node.text === "render idle"))
+ .toBe(true);
+ },
+);
+
+test.skipIf(!endpoint || !appearanceFixture)(
+ "rounded borders follow the background curve",
+ async () => {
+ const node = await nodeWithId("rounded-border");
+ const { png } = await request<{ png: string }>("screenshot", { tag: node.tag });
+ const bytes = Buffer.from(png, "base64");
+ const directory = path.resolve(import.meta.dirname, "../../../.artifacts/gpui/appearance");
+ mkdirSync(directory, { recursive: true });
+ writeFileSync(path.join(directory, "rounded-border.png"), bytes);
+ const { data, info } = await sharp(bytes)
+ .removeAlpha()
+ .raw()
+ .toBuffer({ resolveWithObject: true });
+ const pixel = (x: number, y: number) => {
+ const offset =
+ (Math.floor((y * info.height) / 100) * info.width + Math.floor((x * info.width) / 200)) *
+ info.channels;
+ return [...data.subarray(offset, offset + 3)];
+ };
+ // Outside the arc must be clear; the diagonal must have an actual curved
+ // border, not four straight rules clipped to a rounded rectangle.
+ expect(pixel(1, 1)).toEqual([255, 255, 255]);
+ expect(pixel(8, 8)).toEqual([255, 0, 0]);
+ expect(pixel(15, 15)).toEqual([0, 0, 255]);
+ expect(pixel(100, 1)).toEqual([255, 0, 0]);
+ expect(pixel(198, 98)).toEqual([255, 255, 255]);
+ },
+);
+
+test.skipIf(!endpoint || !appearanceFixture)(
+ "Pressable hover highlights enter and leave",
+ async () => {
+ const button = await nodeWithId("hover-button");
+ const outside = await nodeWithId("outside-hover");
+ await request("hover", { tag: button.tag });
+ await expectBackground(button, 0xff00ff00);
+ await request("hover", { tag: outside.tag });
+ await expectBackground(button, 0xff0000ff);
+ },
+);
+
+test.skipIf(!endpoint || !appearanceFixture)(
+ "row hover survives entering a nested Pressable",
+ async () => {
+ const row = await nodeWithId("hover-row");
+ const child = await nodeWithId("nested-button");
+ const outside = await nodeWithId("outside-hover");
+ await request("hover", { tag: row.tag });
+ await request("hover", { tag: child.tag });
+ await expectBackground(row, 0xff00ff00);
+ await request("hover", { tag: outside.tag });
+ await expectBackground(row, 0xff0000ff);
+ },
+);
diff --git a/packages/app/gpui/verify-macos-package.mjs b/packages/app/gpui/verify-macos-package.mjs
new file mode 100644
index 0000000000..4f9538e5d1
--- /dev/null
+++ b/packages/app/gpui/verify-macos-package.mjs
@@ -0,0 +1,61 @@
+import assert from "node:assert/strict";
+import { execFileSync, spawnSync } from "node:child_process";
+import { readdirSync } from "node:fs";
+import path from "node:path";
+
+assert.equal(process.platform, "darwin", "Run package verification on macOS");
+assert.ok(process.argv[2], "Pass the packaged .app path");
+const app = path.resolve(process.argv[2]);
+const contents = path.join(app, "Contents");
+const info = JSON.parse(
+ execFileSync("plutil", ["-convert", "json", "-o", "-", path.join(contents, "Info.plist")]),
+);
+const executable = path.join(contents, "MacOS", info.CFBundleExecutable);
+const frameworks = path.join(contents, "Frameworks");
+const libraries = readdirSync(frameworks, { recursive: true })
+ .filter((name) => name.endsWith(".dylib"))
+ .map((name) => path.join(frameworks, name));
+
+execFileSync("codesign", ["--verify", "--deep", "--strict", app], { stdio: "inherit" });
+const signature = spawnSync("codesign", ["-dvvv", executable], { encoding: "utf8" });
+assert.equal(signature.status, 0, signature.stderr);
+if (
+ signature.stderr.includes("Signature=adhoc") &&
+ /flags=[^\n]*\bruntime\b/.test(signature.stderr) &&
+ libraries.length > 0
+) {
+ const entitlements = execFileSync("codesign", ["-d", "--entitlements", "-", "--xml", executable]);
+ assert.ok(entitlements.length > 0, "Ad-hoc app has no library-validation entitlement");
+ const values = JSON.parse(
+ execFileSync("plutil", ["-convert", "json", "-o", "-", "--", "-"], {
+ input: entitlements,
+ }),
+ );
+ assert.equal(
+ values["com.apple.security.cs.disable-library-validation"],
+ true,
+ "Ad-hoc app cannot satisfy Team ID library validation on a Mac with SIP enabled",
+ );
+}
+
+function version(value) {
+ const parts = value.split(".").map(Number);
+ return (parts[0] * 256 + (parts[1] ?? 0)) * 256 + (parts[2] ?? 0);
+}
+
+assert.ok(info.LSMinimumSystemVersion, "The app must declare its minimum macOS version");
+for (const binary of [executable, ...libraries]) {
+ const build = execFileSync("xcrun", ["vtool", "-show-build", binary], { encoding: "utf8" });
+ const minima = [...build.matchAll(/\bminos\s+(\d+(?:\.\d+)*)/g)].map((match) => match[1]);
+ assert.ok(minima.length > 0, `No macOS deployment target found in ${binary}`);
+ for (const minimum of minima) {
+ assert.ok(
+ version(minimum) <= version(info.LSMinimumSystemVersion),
+ `${path.basename(binary)} requires macOS ${minimum}, above the app's ${info.LSMinimumSystemVersion}`,
+ );
+ }
+}
+
+console.log(
+ `PASS: bundle signatures, ad-hoc library loading policy, and macOS deployment targets: ${app}`,
+);
diff --git a/packages/app/gpui/websocket-regression.fixture.js b/packages/app/gpui/websocket-regression.fixture.js
new file mode 100644
index 0000000000..d6c016d5e4
--- /dev/null
+++ b/packages/app/gpui/websocket-regression.fixture.js
@@ -0,0 +1,45 @@
+import { AppRegistry } from "react-native";
+
+AppRegistry.registerComponent("Paseo", () => () => null);
+
+// The native test supplies a loopback server before loading this bundle.
+const base = globalThis.__webSocketRegressionUrl;
+async function connect(path, expectedCode, localClose = false) {
+ return new Promise((resolve, reject) => {
+ const socket = new WebSocket(`${base}/${path}`);
+ const timeout = setTimeout(() => reject(new Error(`${path}: timed out`)), 5000);
+ let echoed = false;
+ socket.addEventListener("open", () => socket.send("native websocket regression"));
+ socket.addEventListener("message", (event) => {
+ echoed = event.data === "native websocket regression";
+ if (localClose) socket.close(1000, "client done");
+ });
+ socket.addEventListener("close", (event) => {
+ clearTimeout(timeout);
+ if (event.code !== expectedCode || (expectedCode === 1000 && !echoed)) {
+ reject(new Error(`${path}: code=${event.code}, echoed=${echoed}`));
+ } else {
+ resolve();
+ }
+ });
+ });
+}
+
+async function run() {
+ // Keep retrying through the real RN WebSocket API. Before the fix, native
+ // clients survived terminal events and exhausted the Mac's descriptor limit.
+ for (const [path, code, localClose] of [
+ ["reject", 1006, false],
+ ["remote-close", 1000, false],
+ ["local-close", 1000, true],
+ ]) {
+ for (let attempt = 0; attempt < 128; attempt++) {
+ await connect(path, code, localClose);
+ }
+ console.log(`WEBSOCKET_REGRESSION_PASS ${path}`);
+ }
+ console.log("WEBSOCKET_REGRESSION_COMPLETE");
+}
+setTimeout(() => {
+ run().catch((error) => console.error(`WEBSOCKET_REGRESSION_FAIL ${error.message}`));
+}, 0);
diff --git a/packages/app/gpui/websocket.native.test.ts b/packages/app/gpui/websocket.native.test.ts
new file mode 100644
index 0000000000..268cbd9cd8
--- /dev/null
+++ b/packages/app/gpui/websocket.native.test.ts
@@ -0,0 +1,117 @@
+import { spawn, execFileSync } from "node:child_process";
+import { createServer } from "node:http";
+import { mkdtempSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { Server as WebSocketServer } from "ws";
+import { expect, test } from "vitest";
+
+const binary = process.env.PASEO_GPUI_TEST_BINARY;
+const project = path.dirname(fileURLToPath(import.meta.url));
+const root = path.resolve(project, "../../..");
+
+function createPeer() {
+ const server = createServer();
+ const sockets = new WebSocketServer({ noServer: true });
+ server.on("upgrade", (request, socket, head) => {
+ if (request.url === "/reject") {
+ socket.end("HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
+ return;
+ }
+ sockets.handleUpgrade(request, socket, head, (websocket) => {
+ websocket.on("message", (message) => {
+ websocket.send(message.toString());
+ if (request.url === "/remote-close") websocket.close(1000, "server done");
+ });
+ });
+ });
+ return { server, sockets };
+}
+
+test.skipIf(!binary)(
+ "native WebSocket retries and peer closes release their resources",
+ async () => {
+ mkdirSync(path.join(root, ".artifacts/gpui"), { recursive: true });
+ const artifacts = mkdtempSync(path.join(root, ".artifacts/gpui/websocket-"));
+ const bundle = path.join(artifacts, "regression.bundle");
+ execFileSync(
+ process.execPath,
+ [
+ "node_modules/react-native/cli.js",
+ "bundle",
+ "--platform",
+ "desktop",
+ "--entry-file",
+ "websocket-regression.fixture.js",
+ "--config",
+ "desktop/metro.config.js",
+ "--bundle-output",
+ bundle,
+ "--dev",
+ "false",
+ "--max-workers",
+ "1",
+ ],
+ { cwd: project, env: { ...process.env, PASEO_RENDERER: "gpui" }, timeout: 120_000 },
+ );
+
+ const { server, sockets } = createPeer();
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
+ const address = server.address();
+ if (!address || typeof address === "string") throw new Error("Missing server address");
+ writeFileSync(
+ bundle,
+ `globalThis.__webSocketRegressionUrl = "ws://127.0.0.1:${address.port}";\n${readFileSync(bundle, "utf8")}`,
+ );
+ // Match Finder's descriptor limit even when the test shell has raised it.
+ const app = spawn("/bin/sh", ["-c", 'ulimit -n 256; exec "$@"', "gpui-test", binary!], {
+ env: {
+ ...process.env,
+ RN_GPUI_BUNDLE: bundle,
+ RN_GPUI_APP_ID: "sh.paseo.gpui.websocket-test",
+ XDG_DATA_HOME: path.join(artifacts, "data"),
+ },
+ stdio: ["ignore", "pipe", "pipe"],
+ });
+ let output = "";
+ try {
+ await new Promise((resolve, reject) => {
+ const timeout = setTimeout(
+ () => reject(new Error(`Native retry test timed out:\n${output}`)),
+ 60_000,
+ );
+ const collect = (chunk: Buffer) => {
+ output += chunk.toString();
+ if (output.includes("WEBSOCKET_REGRESSION_COMPLETE")) {
+ clearTimeout(timeout);
+ resolve();
+ } else if (output.includes("WEBSOCKET_REGRESSION_FAIL")) {
+ clearTimeout(timeout);
+ reject(new Error(output));
+ }
+ };
+ app.stdout.on("data", collect);
+ app.stderr.on("data", collect);
+ app.once("error", (error) => {
+ clearTimeout(timeout);
+ reject(error);
+ });
+ app.once("exit", (code, signal) => {
+ clearTimeout(timeout);
+ reject(new Error(`Native process exited (${code}, ${signal}):\n${output}`));
+ });
+ });
+ expect(app.exitCode).toBeNull();
+ expect(output).toContain("WEBSOCKET_REGRESSION_PASS reject");
+ expect(output).toContain("WEBSOCKET_REGRESSION_PASS remote-close");
+ expect(output).toContain("WEBSOCKET_REGRESSION_PASS local-close");
+ } finally {
+ writeFileSync(path.join(artifacts, "native.log"), output);
+ app.kill("SIGTERM");
+ for (const client of sockets.clients) client.terminate();
+ sockets.close();
+ server.close();
+ }
+ },
+ 180_000,
+);
diff --git a/vendor/react-native-gpui/UPSTREAM b/vendor/react-native-gpui/UPSTREAM
index 3822ad2679..ac7c3b9571 100644
--- a/vendor/react-native-gpui/UPSTREAM
+++ b/vendor/react-native-gpui/UPSTREAM
@@ -13,8 +13,32 @@ Local patches (all other retained files match the revision above):
install their own pointer area. Otherwise an empty portal overlay redirects
clicks to its parent, blocking Paseo's sidebar. native.test.ts exercises this
against the actual app.
+- packages/core/ReactGpui/rn-gpui/src/{node_view,style}.rs: paint uniform solid
+ borders with GPUI's rounded border primitive on a decoration child, keeping
+ Fabric child coordinates intact. Edge strips cannot draw the corner arcs.
+- packages/core/ReactGpui/rn-gpui-sys/cpp/GpuiJsEngine.h: route Pressability
+ hover through the pointer events GPUI emits. The desktop Pressable wrapper
+ in src-gpui/Libraries/Components/Pressable also supplies hovered to render
+ props; metro.config.js lets a wrapper import its upstream component without
+ resolving back to itself. native.test.ts covers callbacks, render props,
+ nested row hover and border pixels using appearance-regression.fixture.js.
- packages/core/src-gpui/Libraries/Components/StatusBar/StatusBar.desktop.js:
desktop StatusBar implementation, because there is no mobile OS status bar.
+- packages/cli/src/package-desktop.js: ad-hoc sign macOS packages so their
+ bundle resources are sealed, with templates/adhoc.entitlements.plist allowing
+ bundled ad-hoc dylibs under hardened runtime; covered by package-desktop.test.js.
+- packages/cli/src/commands.js: preserve Cargo's generated dSYM symlink so
+ subsequent builds can replace it; copy symbol contents into the distribution
+ instead of linking back into the build tree. Covered by commands.test.js.
+- packages/core/ReactCxxPlatform/react/io/WebSocketModule.{cpp,h}: release
+ failed and closed connections on the JS thread. Keeping terminal clients
+ exhausts file descriptors during daemon retries; deleting them on the IO
+ thread would join that same thread. websocket.native.test.ts exercises the
+ real RN API against a loopback peer. ReactGpui/rn-gpui-sys/build.rs tracks
+ these sources so incremental builds include the fix.
+- packages/core/ReactGpui/rn-gpui-sys/build.rs: pass Cargo's
+ MACOSX_DEPLOYMENT_TARGET to CMake. Otherwise source builds default to the
+ installed SDK's version even when the app targets an older macOS release.
GPUI itself remains a pinned Cargo git dependency. Hermes/React Native prebuilt
libraries are downloaded by upstream build tooling; this is not an offline or
diff --git a/vendor/react-native-gpui/packages/cli/src/commands.js b/vendor/react-native-gpui/packages/cli/src/commands.js
index 06ada51228..f31a4fd382 100644
--- a/vendor/react-native-gpui/packages/cli/src/commands.js
+++ b/vendor/react-native-gpui/packages/cli/src/commands.js
@@ -821,7 +821,7 @@ export function scaffoldDesktop(
beforeWrite(cargoPath);
fs.writeFileSync(
cargoPath,
- `[package]\nname = ${JSON.stringify(appPackageName(projectRoot, packageJson))}\nversion = "0.1.0"\nedition = "2021"\n\n# Set CARGO_PROFILE_DEV_DEBUG=full to opt into full debug information.\n[profile.dev]\ndebug = "line-tables-only"\nsplit-debuginfo = "packed"\n\n# Dependencies are optimized even in a dev build: GPUI rebuilds and paints the\n# element tree on every frame, and they are only compiled once.\n[profile.dev.package."*"]\ndebug = false\nopt-level = 3\n\n# The C++ bridge picks its CMake build type from this crate's opt-level, and a\n# Release build of React Native has no dev server, inspector or Fast Refresh.\n[profile.dev.package.rn-gpui-sys]\nopt-level = 0\n\n[profile.release]\ndebug = "line-tables-only"\n# Cargo strips inside the linker, before dsymutil, so package-desktop writes the\n# dSYM and then strips. Full LTO: 2 MB smaller for 1.4x the build time.\nstrip = "none"\nsplit-debuginfo = "packed"\nlto = "fat"\ncodegen-units = 1\n\n[build-dependencies]\nserde_json = "1"\n\n[dependencies]\n`,
+ `[package]\nname = ${JSON.stringify(appPackageName(projectRoot, packageJson))}\nversion = "0.1.0"\nedition = "2021"\n\n# Set CARGO_PROFILE_DEV_DEBUG=full to opt into full debug information.\n[profile.dev]\ndebug = "line-tables-only"\nsplit-debuginfo = "packed"\n\n# Dependencies are optimized even in a dev build: GPUI rebuilds and paints the\n# element tree on every frame, and they are only compiled once.\n[profile.dev.package."*"]\ndebug = false\nopt-level = 3\n\n# The C++ bridge picks its CMake build type from this crate's opt-level, and a\n# Release build of React Native has no dev server, inspector or Fast Refresh.\n[profile.dev.package.rn-gpui-sys]\nopt-level = 0\n\n[profile.release]\ndebug = "line-tables-only"\n# Cargo strips inside the linker, before dsymutil, so package-desktop preserves the\n# Cargo dSYM and then strips. Full LTO: 2 MB smaller for 1.4x the build time.\nstrip = "none"\nsplit-debuginfo = "packed"\nlto = "fat"\ncodegen-units = 1\n\n[build-dependencies]\nserde_json = "1"\n\n[dependencies]\n`,
);
}
@@ -1538,10 +1538,9 @@ export async function packageDesktop({
const binaryName = desktopCrateName(crateDir);
const buildDir = path.join(crateDir, 'build');
- // The binary is finished. Ask for the debug file first — it can only be
- // collected from a binary that still has its debug info — then take the symbols
- // off, so the packager copies the small binary.
- ensureDsym({ crateDir, binaryName });
+ // Cargo owns the packed debug output and its symlink in the profile directory.
+ // Replacing that symlink with a directory makes the next Cargo build fail.
+ // Keep its output intact and copy the symbols after packaging.
stripReleaseBinary({ crateDir, binaryName });
// 2. Icons, from the one PNG the config names.
@@ -1634,33 +1633,6 @@ export function ensureCargoPackager() {
);
}
-/**
- * Write the release dSYM if it is not there yet.
- *
- * Cargo does not invoke `dsymutil`, so a binary built with `strip = "none"` still
- * has no dSYM until one is asked for. This must happen before `stripReleaseBinary`
- * — the debug info has to be in the binary to be collected out of it.
- */
-function ensureDsym({ crateDir, binaryName }) {
- const profileDir = targetProfileDir(crateDir, 'release');
- const binary = path.join(profileDir, binaryName);
- const dsym = path.join(profileDir, `${binaryName}.dSYM`);
- if (!fs.existsSync(binary)) return;
- // `dsymutil` writes the bundle read-only, and Cargo then cannot replace the
- // binary it belongs to: "failed to remove file … Operation not permitted". Make
- // it writable before removing it.
- if (fs.existsSync(dsym)) fs.chmodSync(dsym, 0o755);
- fs.rmSync(dsym, { recursive: true, force: true });
- try {
- execFileSync('dsymutil', [binary, '-o', dsym], { stdio: 'inherit' });
- // `dsymutil` writes the bundle read-only, and Cargo then cannot replace the
- // binary it belongs to: "failed to remove file … Operation not permitted".
- if (fs.existsSync(dsym)) fs.chmodSync(dsym, 0o755);
- } catch (error) {
- console.log(`could not write a dSYM for ${binaryName}: ${error.message}`);
- }
-}
-
/// Whether `file` is a directory with no files anywhere below it.
function isEmptyDirectory(file) {
const stat = fs.statSync(file);
@@ -1684,7 +1656,7 @@ function isEmptyDirectory(file) {
* function names, and it must not ship: it is larger than the binary and useless
* to anyone who is not symbolizing.
*/
-function copyDebugSymbols({ crateDir, binaryName, outDir }) {
+export function copyDebugSymbols({ crateDir, binaryName, outDir }) {
const profileDir = targetProfileDir(crateDir, 'release');
// `split-debuginfo = "packed"` gives a dSYM on macOS, a `.dwp` on Linux and a
// `.pdb` on Windows. The unpacked `.debug` file is listed too because a
@@ -1708,7 +1680,7 @@ function copyDebugSymbols({ crateDir, binaryName, outDir }) {
for (const file of found) {
const target = path.join(outDir, path.basename(file));
fs.rmSync(target, { recursive: true, force: true });
- fs.cpSync(file, target, { recursive: true });
+ fs.cpSync(file, target, { recursive: true, dereference: true });
// A read-only directory here would make the next run fail to replace it.
fs.chmodSync(target, 0o755);
console.log(`debug symbols: ${target}`);
diff --git a/vendor/react-native-gpui/packages/cli/src/commands.test.js b/vendor/react-native-gpui/packages/cli/src/commands.test.js
index 6296f93f3a..fb1d7d9afd 100644
--- a/vendor/react-native-gpui/packages/cli/src/commands.test.js
+++ b/vendor/react-native-gpui/packages/cli/src/commands.test.js
@@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url';
import { afterAll, expect, test } from 'vitest';
import {
collectNodeAddons,
+ copyDebugSymbols,
createLibrary,
desktopLaunchConfig,
linuxBuildDependencyProblem,
@@ -33,6 +34,29 @@ const repoRoot = (() => {
})();
const temporaryRoots = [];
+test('packaging copies Cargo dSYM contents without retaining its build-tree symlink', () => {
+ fs.mkdirSync(nodePath.join(repoRoot, '.artifacts'), { recursive: true });
+ const root = fs.mkdtempSync(nodePath.join(repoRoot, '.artifacts/gpui-dsym-'));
+ temporaryRoots.push(root);
+ const crateDir = nodePath.join(root, 'desktop');
+ const release = nodePath.join(crateDir, 'target/release');
+ const symbols = nodePath.join(release, 'deps/app-hash.dSYM');
+ const dwarf = 'Contents/Resources/DWARF/app';
+ fs.mkdirSync(nodePath.dirname(nodePath.join(symbols, dwarf)), { recursive: true });
+ fs.writeFileSync(nodePath.join(symbols, dwarf), 'debug symbols');
+ const cargoLink = nodePath.join(release, 'app.dSYM');
+ fs.symlinkSync('deps/app-hash.dSYM', cargoLink);
+ const outDir = nodePath.join(root, 'dist');
+ fs.mkdirSync(outDir);
+
+ copyDebugSymbols({ crateDir, binaryName: 'app', outDir });
+
+ expect(fs.lstatSync(cargoLink).isSymbolicLink()).toBe(true);
+ expect(fs.lstatSync(nodePath.join(outDir, 'app.dSYM')).isSymbolicLink()).toBe(false);
+ fs.rmSync(release, { recursive: true });
+ expect(fs.readFileSync(nodePath.join(outDir, 'app.dSYM', dwarf), 'utf8')).toBe('debug symbols');
+});
+
afterAll(() => {
for (const root of temporaryRoots) {
fs.rmSync(root, { recursive: true, force: true });
diff --git a/vendor/react-native-gpui/packages/cli/src/package-desktop.js b/vendor/react-native-gpui/packages/cli/src/package-desktop.js
index a244260a7a..0c9ef44bff 100644
--- a/vendor/react-native-gpui/packages/cli/src/package-desktop.js
+++ b/vendor/react-native-gpui/packages/cli/src/package-desktop.js
@@ -660,6 +660,16 @@ export function toPackagerConfig(config, build) {
const macos = {
minimumSystemVersion: config.bundle.macos.minimumSystemVersion,
};
+ // The linker's executable signature does not seal the packaged app's
+ // resources. Local macOS packages need a complete ad-hoc bundle signature.
+ if (os === 'macos') {
+ macos.signingIdentity = '-';
+ // cargo-packager enables hardened runtime. Ad-hoc signatures have no Team
+ // ID, so library validation otherwise rejects even the bundled dylibs.
+ macos.entitlements = relative(
+ require.resolve('../templates/adhoc.entitlements.plist'),
+ );
+ }
if (config.bundle.macos.entitlements !== null) {
macos.entitlements = relative(
path.resolve(process.cwd(), config.bundle.macos.entitlements),
diff --git a/vendor/react-native-gpui/packages/cli/src/package-desktop.test.js b/vendor/react-native-gpui/packages/cli/src/package-desktop.test.js
index 1b9d0ee19e..c13cb09fc8 100644
--- a/vendor/react-native-gpui/packages/cli/src/package-desktop.test.js
+++ b/vendor/react-native-gpui/packages/cli/src/package-desktop.test.js
@@ -263,6 +263,24 @@ describe('toPackagerConfig', () => {
expect(config.outDir).toBe('/project/desktop/build/dist');
});
+ test('macOS packages seal the app with an ad-hoc signature', () => {
+ const config = toPackagerConfig(desktopConfig(), build({ os: 'macos' }));
+ expect(config.macos.signingIdentity).toBe('-');
+ });
+
+ test('ad-hoc macOS apps can load their bundled libraries without a Team ID', () => {
+ const context = build({ os: 'macos' });
+ const config = toPackagerConfig(desktopConfig(), context);
+ expect(config.macos.entitlements).toBeTypeOf('string');
+ const entitlements = fs.readFileSync(
+ path.resolve(context.crateDir, config.macos.entitlements),
+ 'utf8',
+ );
+ expect(entitlements).toMatch(
+ /com\.apple\.security\.cs\.disable-library-validation<\/key>\s*/u,
+ );
+ });
+
test('the bundle file and the assets directory ship as resources', () => {
// A directory containing this run's output cannot be shipped, so the two
// are named. The asset directory has to exist to be listed.
diff --git a/vendor/react-native-gpui/packages/cli/templates/adhoc.entitlements.plist b/vendor/react-native-gpui/packages/cli/templates/adhoc.entitlements.plist
new file mode 100644
index 0000000000..2f4ef70fdf
--- /dev/null
+++ b/vendor/react-native-gpui/packages/cli/templates/adhoc.entitlements.plist
@@ -0,0 +1,8 @@
+
+
+
+
+ com.apple.security.cs.disable-library-validation
+
+
+
diff --git a/vendor/react-native-gpui/packages/core/ReactCxxPlatform/react/io/WebSocketModule.cpp b/vendor/react-native-gpui/packages/core/ReactCxxPlatform/react/io/WebSocketModule.cpp
index 3ed94c2d1c..f63ab695fb 100644
--- a/vendor/react-native-gpui/packages/core/ReactCxxPlatform/react/io/WebSocketModule.cpp
+++ b/vendor/react-native-gpui/packages/core/ReactCxxPlatform/react/io/WebSocketModule.cpp
@@ -51,6 +51,20 @@ WebSocketModule::~WebSocketModule() {
connections_.clear();
}
+void WebSocketModule::releaseConnection(int32_t socketID) {
+ // Only the JS thread owns connections_. Destroying a client joins its IO
+ // thread, so terminal callbacks must dispatch here rather than delete it
+ // from that IO thread. RN's JS WebSocket is already CLOSED when it receives
+ // a terminal event and does not call the native close method afterward.
+ auto it = connections_.find(socketID);
+ if (it == connections_.end()) {
+ return;
+ }
+ removeSocketSender(socketID);
+ setBlobWebSocketHandler(socketID, false);
+ connections_.erase(it);
+}
+
void WebSocketModule::connect(
jsi::Runtime& /*rt*/,
const std::string& url,
@@ -119,24 +133,29 @@ void WebSocketModule::connect(
});
connections_[socketID]->setOnClosedFrameCallback(
[weakThis = weak_from_this(), socketID](uint16_t code, const std::string& reason) {
- removeSocketSender(socketID);
- setBlobWebSocketHandler(socketID, false);
auto strongThis = weakThis.lock();
if (!strongThis) {
return;
}
- strongThis->emitDeviceEvent(
- "websocketClosed",
- [socketID, code, reason](jsi::Runtime& rt, std::vector& args) {
- auto arg = jsi::Object(rt);
- arg.setProperty(rt, "id", jsi::Value(socketID));
- arg.setProperty(rt, "code", jsi::Value(code));
- arg.setProperty(
- rt,
- "reason",
- facebook::jsi::String::createFromUtf8(rt, reason));
- args.emplace_back(rt, arg);
- });
+ strongThis->jsInvoker_->invokeAsync([weakThis, socketID, code, reason](jsi::Runtime&) {
+ auto owner = weakThis.lock();
+ if (!owner || owner->connections_.find(socketID) == owner->connections_.end()) {
+ return;
+ }
+ owner->releaseConnection(socketID);
+ owner->emitDeviceEvent(
+ "websocketClosed",
+ [socketID, code, reason](jsi::Runtime& rt, std::vector& args) {
+ auto arg = jsi::Object(rt);
+ arg.setProperty(rt, "id", jsi::Value(socketID));
+ arg.setProperty(rt, "code", jsi::Value(code));
+ arg.setProperty(
+ rt,
+ "reason",
+ facebook::jsi::String::createFromUtf8(rt, reason));
+ args.emplace_back(rt, arg);
+ });
+ });
});
connections_[socketID]->connect(
@@ -156,18 +175,25 @@ void WebSocketModule::connect(
args.emplace_back(rt, arg);
});
} else {
- strongThis->emitDeviceEvent(
- "websocketFailed",
- [socketID](jsi::Runtime& rt, std::vector& args) {
- auto arg = jsi::Object(rt);
- arg.setProperty(rt, "id", jsi::Value(socketID));
- arg.setProperty(
- rt,
- "message",
- facebook::jsi::String::createFromAscii(
- rt, "Could not connect to websocket."));
- args.emplace_back(rt, arg);
- });
+ strongThis->jsInvoker_->invokeAsync([weakThis, socketID](jsi::Runtime&) {
+ auto owner = weakThis.lock();
+ if (!owner || owner->connections_.find(socketID) == owner->connections_.end()) {
+ return;
+ }
+ owner->releaseConnection(socketID);
+ owner->emitDeviceEvent(
+ "websocketFailed",
+ [socketID](jsi::Runtime& rt, std::vector& args) {
+ auto arg = jsi::Object(rt);
+ arg.setProperty(rt, "id", jsi::Value(socketID));
+ arg.setProperty(
+ rt,
+ "message",
+ facebook::jsi::String::createFromAscii(
+ rt, "Could not connect to websocket."));
+ args.emplace_back(rt, arg);
+ });
+ });
}
});
}
@@ -237,9 +263,6 @@ void WebSocketModule::close(
return;
}
it->second->close(static_cast(code), reason);
- removeSocketSender(socketID);
- setBlobWebSocketHandler(socketID, false);
- connections_.erase(it);
}
void WebSocketModule::addListener(
diff --git a/vendor/react-native-gpui/packages/core/ReactCxxPlatform/react/io/WebSocketModule.h b/vendor/react-native-gpui/packages/core/ReactCxxPlatform/react/io/WebSocketModule.h
index 8ade7372df..3c81c90fcd 100644
--- a/vendor/react-native-gpui/packages/core/ReactCxxPlatform/react/io/WebSocketModule.h
+++ b/vendor/react-native-gpui/packages/core/ReactCxxPlatform/react/io/WebSocketModule.h
@@ -48,6 +48,8 @@ class WebSocketModule : public NativeWebSocketModuleCxxSpec,
void removeListeners(jsi::Runtime &rt, int32_t count);
private:
+ void releaseConnection(int32_t socketID);
+
WebSocketClientFactory webSocketClientFactory_;
std::unordered_map> connections_;
};
diff --git a/vendor/react-native-gpui/packages/core/ReactGpui/rn-gpui-sys/build.rs b/vendor/react-native-gpui/packages/core/ReactGpui/rn-gpui-sys/build.rs
index 75c5013b64..dcb7191aae 100644
--- a/vendor/react-native-gpui/packages/core/ReactGpui/rn-gpui-sys/build.rs
+++ b/vendor/react-native-gpui/packages/core/ReactGpui/rn-gpui-sys/build.rs
@@ -99,6 +99,8 @@ fn main() {
for changed_platform_source in [
"react/io/ImageLoaderModule.cpp",
"react/io/ImageLoaderModule.h",
+ "react/io/WebSocketModule.cpp",
+ "react/io/WebSocketModule.h",
"react/runtime/ReactCxxTurboModuleProvider.cpp",
"react/runtime/ReactCxxTurboModuleProvider.h",
"react/runtime/ReactHost.cpp",
@@ -308,6 +310,11 @@ fn main() {
// the path (the /usr/bin shim or the Xcode toolchain binary behind it), so
// name the shim unless the user chose a compiler.
if cfg!(target_os = "macos") && target_triple.contains("apple") {
+ // CMake otherwise defaults to the installed SDK, independently of
+ // Cargo's deployment target (including when reusing its cache).
+ if let Ok(minimum) = env::var("MACOSX_DEPLOYMENT_TARGET") {
+ defines.push(("CMAKE_OSX_DEPLOYMENT_TARGET", minimum));
+ }
for (variable, tool, env_name) in [
("CMAKE_C_COMPILER", "/usr/bin/cc", "CC"),
("CMAKE_CXX_COMPILER", "/usr/bin/c++", "CXX"),
diff --git a/vendor/react-native-gpui/packages/core/ReactGpui/rn-gpui-sys/cpp/GpuiJsEngine.h b/vendor/react-native-gpui/packages/core/ReactGpui/rn-gpui-sys/cpp/GpuiJsEngine.h
index f7424beacb..4bcd0a4654 100644
--- a/vendor/react-native-gpui/packages/core/ReactGpui/rn-gpui-sys/cpp/GpuiJsEngine.h
+++ b/vendor/react-native-gpui/packages/core/ReactGpui/rn-gpui-sys/cpp/GpuiJsEngine.h
@@ -31,6 +31,11 @@ class GpuiFeatureFlagsOverrides
bool cxxNativeAnimatedEnabled() override {
return true;
}
+
+ // GPUI dispatches W3C pointer events, not browser mouseenter/mouseleave.
+ bool shouldPressibilityUseW3CPointerEventsForHover() override {
+ return true;
+ }
};
struct GpuiJsEngineInfo {
diff --git a/vendor/react-native-gpui/packages/core/ReactGpui/rn-gpui/src/node_view.rs b/vendor/react-native-gpui/packages/core/ReactGpui/rn-gpui/src/node_view.rs
index 50af63744f..0870964161 100644
--- a/vendor/react-native-gpui/packages/core/ReactGpui/rn-gpui/src/node_view.rs
+++ b/vendor/react-native-gpui/packages/core/ReactGpui/rn-gpui/src/node_view.rs
@@ -3932,10 +3932,16 @@ impl Render for RNNodeView {
true,
),
];
+ // GPUI paints a complete rounded border, including the inner curve.
+ // Four edge rules cannot represent it, even with rounded clipping.
+ let native_border = self.view.border_colors[0] != 0
+ && edges.iter().any(|(_, _, _, _, thickness, _)| *thickness > 0.0)
+ && self.view.border_colors.iter().all(|color| *color == self.view.border_colors[0])
+ && self.view.border_styles.iter().all(|style| *style == 0);
let mut border_rules = Vec::new();
for (edge, x, y, length, thickness, horizontal) in edges {
let color = self.view.border_colors[edge];
- if color != 0 && thickness > 0.0 {
+ if !native_border && color != 0 && thickness > 0.0 {
border_rules.extend(rule_segments(
x,
y,
@@ -3947,7 +3953,7 @@ impl Render for RNNodeView {
));
}
}
- if !border_rules.is_empty() {
+ if native_border || !border_rules.is_empty() {
let mut border_clip = div()
.absolute()
.top_0()
@@ -3963,7 +3969,19 @@ impl Render for RNNodeView {
radii.bottom_left =
Some(px(self.view.border_radii.bl * scale_x.min(scale_y)).into());
}
- element = element.child(border_clip.children(border_rules));
+ if native_border {
+ // Keep the border on a decoration child: adding border widths
+ // to the Fabric node itself would inset its Yoga-laid-out children.
+ let border = border_clip.style();
+ border.border_color = Some(style::color(self.view.border_colors[0]));
+ border.border_widths.left = Some(px(self.view.border_widths.left * scale_x).into());
+ border.border_widths.top = Some(px(self.view.border_widths.top * scale_y).into());
+ border.border_widths.right = Some(px(self.view.border_widths.right * scale_x).into());
+ border.border_widths.bottom = Some(px(self.view.border_widths.bottom * scale_y).into());
+ element = element.child(border_clip);
+ } else {
+ element = element.child(border_clip.children(border_rules));
+ }
}
let mut outline_rules = Vec::new();
if self.view.outline_color != 0 && self.view.outline_width > 0.0 {
diff --git a/vendor/react-native-gpui/packages/core/ReactGpui/rn-gpui/src/style.rs b/vendor/react-native-gpui/packages/core/ReactGpui/rn-gpui/src/style.rs
index 574d5411fb..47af33ba5b 100644
--- a/vendor/react-native-gpui/packages/core/ReactGpui/rn-gpui/src/style.rs
+++ b/vendor/react-native-gpui/packages/core/ReactGpui/rn-gpui/src/style.rs
@@ -60,8 +60,8 @@ pub fn to_refinement(v: &ViewPropsFlat) -> StyleRefinement {
if v.background_color != 0 {
s.background = Some(Fill::from(color(v.background_color)));
}
- // RN borders are painted as four independent edge rules by `node_view`.
- // GPUI border widths would add a second, default-colored edge on top.
+ // `node_view` owns the border decoration. Border widths on this element
+ // would also inset children that already have their Fabric layout.
s.corner_radii.top_left = Some(px(v.border_radii.tl).into());
s.corner_radii.top_right = Some(px(v.border_radii.tr).into());
s.corner_radii.bottom_right = Some(px(v.border_radii.br).into());
diff --git a/vendor/react-native-gpui/packages/core/metro.config.js b/vendor/react-native-gpui/packages/core/metro.config.js
index 90b791f776..dde05d528c 100644
--- a/vendor/react-native-gpui/packages/core/metro.config.js
+++ b/vendor/react-native-gpui/packages/core/metro.config.js
@@ -231,7 +231,11 @@ function makeMetroConfig({ projectRoot = process.cwd() } = {}) {
}
}
const override = key && overrides.get(key);
- return override ? withFilePath(resolution, override) : resolution;
+ // A platform wrapper can import the component it wraps without resolving
+ // that import back to itself (e.g. desktop Pressable wrapping RN Pressable).
+ return override && override !== context.originModulePath
+ ? withFilePath(resolution, override)
+ : resolution;
};
const config = mergeConfig(getDefaultConfig(projectRoot), {
diff --git a/vendor/react-native-gpui/packages/core/src-gpui/Libraries/Components/Pressable/Pressable.desktop.js b/vendor/react-native-gpui/packages/core/src-gpui/Libraries/Components/Pressable/Pressable.desktop.js
new file mode 100644
index 0000000000..6043a0a6f6
--- /dev/null
+++ b/vendor/react-native-gpui/packages/core/src-gpui/Libraries/Components/Pressable/Pressable.desktop.js
@@ -0,0 +1,36 @@
+import React, { forwardRef, memo, useCallback, useState } from 'react';
+import NativePressable from './Pressable';
+
+// Keep RN's press, focus, accessibility and delayed-hover handling. Desktop
+// also exposes hover state to style/children render props, as RN Web does.
+const Pressable = forwardRef(function DesktopPressable(
+ { style, children, onHoverIn, onHoverOut, disabled, ...props },
+ ref,
+) {
+ const [pointerInside, setPointerInside] = useState(false);
+ const hovered = pointerInside && !disabled;
+ const handleHoverIn = useCallback(event => {
+ setPointerInside(true);
+ onHoverIn?.(event);
+ }, [onHoverIn]);
+ const handleHoverOut = useCallback(event => {
+ setPointerInside(false);
+ onHoverOut?.(event);
+ }, [onHoverOut]);
+ const renderStyle = useCallback(state => style({ ...state, hovered }), [style, hovered]);
+ const renderChildren = useCallback(state => children({ ...state, hovered }), [children, hovered]);
+ return (
+
+ {typeof children === 'function' ? renderChildren : children}
+
+ );
+});
+
+export default memo(Pressable);