From 291c8ad1a809263cd960586bd4fd759beeb60540 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 8 Nov 2026 04:40:38 +0100 Subject: [PATCH] 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. --- packages/app/gpui/README.md | 96 ++++++++++++-- packages/app/gpui/adapters/intl-timezone.js | 5 + .../app/gpui/adapters/intl-timezone.test.ts | 23 ++++ packages/app/gpui/adapters/intl.js | 3 +- .../app/gpui/appearance-regression.fixture.js | 54 ++++++++ packages/app/gpui/desktop/Cargo.toml | 4 +- packages/app/gpui/desktop/desktop.json | 5 +- packages/app/gpui/native.test.ts | 100 ++++++++++++++- packages/app/gpui/verify-macos-package.mjs | 61 +++++++++ .../app/gpui/websocket-regression.fixture.js | 45 +++++++ packages/app/gpui/websocket.native.test.ts | 117 ++++++++++++++++++ vendor/react-native-gpui/UPSTREAM | 24 ++++ .../packages/cli/src/commands.js | 40 +----- .../packages/cli/src/commands.test.js | 24 ++++ .../packages/cli/src/package-desktop.js | 10 ++ .../packages/cli/src/package-desktop.test.js | 18 +++ .../cli/templates/adhoc.entitlements.plist | 8 ++ .../react/io/WebSocketModule.cpp | 81 +++++++----- .../react/io/WebSocketModule.h | 2 + .../core/ReactGpui/rn-gpui-sys/build.rs | 7 ++ .../ReactGpui/rn-gpui-sys/cpp/GpuiJsEngine.h | 5 + .../core/ReactGpui/rn-gpui/src/node_view.rs | 24 +++- .../core/ReactGpui/rn-gpui/src/style.rs | 4 +- .../packages/core/metro.config.js | 6 +- .../Components/Pressable/Pressable.desktop.js | 36 ++++++ 25 files changed, 714 insertions(+), 88 deletions(-) create mode 100644 packages/app/gpui/adapters/intl-timezone.js create mode 100644 packages/app/gpui/adapters/intl-timezone.test.ts create mode 100644 packages/app/gpui/appearance-regression.fixture.js create mode 100644 packages/app/gpui/verify-macos-package.mjs create mode 100644 packages/app/gpui/websocket-regression.fixture.js create mode 100644 packages/app/gpui/websocket.native.test.ts create mode 100644 vendor/react-native-gpui/packages/cli/templates/adhoc.entitlements.plist create mode 100644 vendor/react-native-gpui/packages/core/src-gpui/Libraries/Components/Pressable/Pressable.desktop.js 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);