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);