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.
This commit is contained in:
Mohamed Boudra
2026-11-08 04:40:38 +01:00
parent 6d247815fd
commit 291c8ad1a8
25 changed files with 714 additions and 88 deletions
+84 -12
View File
@@ -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/`.
@@ -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);
}
@@ -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);
});
+2 -1
View File
@@ -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());
@@ -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 (
<View style={{ flex: 1, backgroundColor: "white", padding: 40, gap: 24 }}>
<View
testID="rounded-border"
style={{
width: 200,
height: 100,
borderRadius: 24,
borderWidth: 4,
borderColor: "#ff0000",
backgroundColor: "#0000ff",
}}
/>
<Pressable
testID="hover-button"
onHoverIn={() => setHovered(true)}
onHoverOut={() => setHovered(false)}
style={{ width: 200, height: 60, backgroundColor: buttonHovered ? "#00ff00" : "#0000ff" }}
>
<Text>{buttonHovered ? "hover active" : "hover inactive"}</Text>
</Pressable>
<Pressable
testID="hover-render-prop"
style={({ hovered }) => ({
width: 200,
height: 60,
backgroundColor: hovered ? "#00ff00" : "#0000ff",
})}
>
{({ hovered }) => <Text>{hovered ? "render hovered" : "render idle"}</Text>}
</Pressable>
<View
testID="hover-row"
onPointerEnter={() => setEntered(true)}
onPointerLeave={() => setEntered(false)}
style={{ width: 200, height: 60, backgroundColor: entered ? "#00ff00" : "#0000ff" }}
>
<Pressable testID="nested-button" style={{ width: 100, height: 60 }}>
<Text>{entered ? "row active" : "row inactive"}</Text>
</Pressable>
</View>
<View testID="outside-hover" style={{ width: 200, height: 60 }} />
</View>
);
}
AppRegistry.registerComponent("Paseo", () => AppearanceRegression);
+2 -2
View File
@@ -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"
+4 -1
View File
@@ -1 +1,4 @@
{ "identifier": "sh.paseo.gpui" }
{
"identifier": "sh.paseo.gpui",
"bundle": { "macos": { "minimumSystemVersion": "15.0" } }
}
+97 -3
View File
@@ -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);
},
);
@@ -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}`,
);
@@ -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);
+117
View File
@@ -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<void>((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<void>((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,
);
+24
View File
@@ -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
+6 -34
View File
@@ -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}`);
@@ -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 });
@@ -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),
@@ -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(
/<key>com\.apple\.security\.cs\.disable-library-validation<\/key>\s*<true\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.
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
</plist>
@@ -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<jsi::Value>& 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<jsi::Value>& 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<jsi::Value>& 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<jsi::Value>& 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<uint16_t>(code), reason);
removeSocketSender(socketID);
setBlobWebSocketHandler(socketID, false);
connections_.erase(it);
}
void WebSocketModule::addListener(
@@ -48,6 +48,8 @@ class WebSocketModule : public NativeWebSocketModuleCxxSpec<WebSocketModule>,
void removeListeners(jsi::Runtime &rt, int32_t count);
private:
void releaseConnection(int32_t socketID);
WebSocketClientFactory webSocketClientFactory_;
std::unordered_map<int32_t, std::unique_ptr<IWebSocketClient>> connections_;
};
@@ -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"),
@@ -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 {
@@ -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 {
@@ -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());
+5 -1
View File
@@ -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), {
@@ -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 (
<NativePressable
{...props}
ref={ref}
disabled={disabled}
onHoverIn={handleHoverIn}
onHoverOut={handleHoverOut}
style={typeof style === 'function' ? renderStyle : style}
>
{typeof children === 'function' ? renderChildren : children}
</NativePressable>
);
});
export default memo(Pressable);