@felipe-software/react-native-gpui 0.0.0-placeholder → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.reanimated-prebuilt/react-native-reanimated.mjs +29373 -0
- package/LICENSE +21 -0
- package/README.md +658 -1
- package/cli/bin.ts +379 -0
- package/cli/bundle.ts +32 -0
- package/cli/commands/do.ts +213 -0
- package/cli/commands/flow.ts +283 -0
- package/cli/commands/get.ts +584 -0
- package/cli/commands/shot.ts +312 -0
- package/cli/commands/trace.ts +293 -0
- package/cli/host.ts +714 -0
- package/cli/hotReload.ts +210 -0
- package/cli/run.ts +66 -0
- package/cli/selectors.ts +205 -0
- package/dist/Animated.d.ts +120 -0
- package/dist/ChatList.d.ts +28 -0
- package/dist/Dimensions.d.ts +31 -0
- package/dist/StyleSheet.d.ts +18 -0
- package/dist/Window.d.ts +33 -0
- package/dist/apis.d.ts +165 -0
- package/dist/app-state.d.ts +27 -0
- package/dist/chat-list-types.d.ts +136 -0
- package/dist/colors.d.ts +32 -0
- package/dist/commands.d.ts +230 -0
- package/dist/components.d.ts +419 -0
- package/dist/hotModules.d.ts +1 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.development.js +14876 -0
- package/dist/index.js +12 -0
- package/dist/keyboard.d.ts +140 -0
- package/dist/native-menu.d.ts +7 -0
- package/dist/platform-driver.d.ts +32 -0
- package/dist/platform-services.d.ts +35 -0
- package/dist/portal.d.ts +43 -0
- package/dist/raf.d.ts +4 -0
- package/dist/reanimated/colors-processor-shim.d.ts +10 -0
- package/dist/reanimated/reanimated-host.d.ts +10 -0
- package/dist/reanimated/seam.d.ts +34 -0
- package/dist/reanimated/ui-entry.d.ts +2 -0
- package/dist/reanimated/worklet-channel.d.ts +10 -0
- package/dist/reanimated/worklet-runtime.d.ts +466 -0
- package/dist/reanimated/worklets.d.ts +199 -0
- package/dist/reconciler.d.ts +102 -0
- package/dist/refresh.d.ts +1 -0
- package/dist/render.d.ts +37 -0
- package/dist/runtime.d.ts +235 -0
- package/dist/surfaces.d.ts +51 -0
- package/dist/types.d.ts +276 -0
- package/dist/ui-runtime.js +42276 -0
- package/dist/window-context.d.ts +7 -0
- package/dist/window-host.d.ts +41 -0
- package/dist/wire-delta.d.ts +20 -0
- package/package.json +177 -1
- package/scripts/bundle-app.mjs +88 -0
- package/scripts/conformance-utils.mjs +293 -0
- package/scripts/hot-update-alias-plugin.mjs +88 -0
- package/scripts/native-package-exports-plugin.mjs +235 -0
- package/scripts/pixel.mjs +152 -0
- package/scripts/png.mjs +198 -0
- package/scripts/react-refresh-swc.mjs +35 -0
- package/scripts/reanimated-bun-plugin.mjs +188 -0
- package/scripts/service-binary.d.mts +27 -0
- package/scripts/service-binary.mjs +100 -0
- package/src/reanimated/colors-processor-shim.ts +119 -0
- package/src/reanimated/reanimated-host.ts +69 -0
- package/src/reanimated/rngpui-reanimated-prebuilt.d.ts +7 -0
- package/src/reanimated/seam.ts +566 -0
- package/src/reanimated/ui-entry.ts +80 -0
- package/src/reanimated/worklet-channel.ts +64 -0
- package/src/reanimated/worklet-runtime.ts +2569 -0
- package/src/reanimated/worklets.ts +1334 -0
- package/src/refresh.ts +33 -0
package/cli/host.ts
ADDED
|
@@ -0,0 +1,714 @@
|
|
|
1
|
+
// Launch / attach plumbing for the `rngpui` developer CLI.
|
|
2
|
+
//
|
|
3
|
+
// The only launch model is the JavaScriptCore one-process host:
|
|
4
|
+
// entry.tsx -> bundle-app.mjs -> app.js -> rngpui-service
|
|
5
|
+
//
|
|
6
|
+
// Bun is used as a compiler only. The app runtime is always the Rust service with
|
|
7
|
+
// embedded JavaScriptCore, and debug commands go over RNGPUI_CONTROL_SOCKET.
|
|
8
|
+
|
|
9
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
10
|
+
import { createConnection } from "node:net";
|
|
11
|
+
import {
|
|
12
|
+
copyFileSync,
|
|
13
|
+
existsSync,
|
|
14
|
+
mkdtempSync,
|
|
15
|
+
mkdirSync,
|
|
16
|
+
openSync,
|
|
17
|
+
readdirSync,
|
|
18
|
+
readFileSync,
|
|
19
|
+
rmSync,
|
|
20
|
+
statSync,
|
|
21
|
+
writeFileSync,
|
|
22
|
+
} from "node:fs";
|
|
23
|
+
import { tmpdir } from "node:os";
|
|
24
|
+
import { dirname, join, resolve } from "node:path";
|
|
25
|
+
import { execFileSync } from "node:child_process";
|
|
26
|
+
import { captureWindow, listWindows, sleep, waitForServicePid, waitForWindow } from "../scripts/conformance-utils.mjs";
|
|
27
|
+
import { packageRoot, resolveServiceBinary, uiRuntimeEnv } from "../scripts/service-binary.mjs";
|
|
28
|
+
import { bundleApp } from "./bundle";
|
|
29
|
+
|
|
30
|
+
export const tsRoot = packageRoot;
|
|
31
|
+
|
|
32
|
+
export interface GpuiWindow {
|
|
33
|
+
id: number;
|
|
34
|
+
window_id: number;
|
|
35
|
+
pid: number;
|
|
36
|
+
owner: string;
|
|
37
|
+
title: string;
|
|
38
|
+
layer: number;
|
|
39
|
+
x: number;
|
|
40
|
+
y: number;
|
|
41
|
+
width: number;
|
|
42
|
+
height: number;
|
|
43
|
+
bounds: { x: number; y: number; width: number; height: number };
|
|
44
|
+
rngpui?: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface LaunchedHost {
|
|
48
|
+
mode: "launch";
|
|
49
|
+
pid: number;
|
|
50
|
+
servicePid: number;
|
|
51
|
+
window: GpuiWindow;
|
|
52
|
+
sessionDir: string;
|
|
53
|
+
appName: string;
|
|
54
|
+
controlSocketPath: string;
|
|
55
|
+
request<T = unknown>(cmd: object): Promise<T>;
|
|
56
|
+
dump(): Promise<DumpNode>;
|
|
57
|
+
capture(path: string): void;
|
|
58
|
+
close(): void;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface AttachedHost {
|
|
62
|
+
mode: "attach";
|
|
63
|
+
servicePid: number;
|
|
64
|
+
window: GpuiWindow;
|
|
65
|
+
appName?: string;
|
|
66
|
+
controlSocketPath?: string;
|
|
67
|
+
dumpTreePath?: string;
|
|
68
|
+
request?<T = unknown>(cmd: object): Promise<T>;
|
|
69
|
+
capture(path: string): void;
|
|
70
|
+
dump(): Promise<DumpNode | null>;
|
|
71
|
+
close(): void;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export type Host = LaunchedHost | AttachedHost;
|
|
75
|
+
|
|
76
|
+
export type DriveableHost = Host & {
|
|
77
|
+
request<T = unknown>(cmd: object): Promise<T>;
|
|
78
|
+
dump(): Promise<DumpNode>;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export function isDriveableHost(host: Host): host is DriveableHost {
|
|
82
|
+
return typeof (host as { request?: unknown }).request === "function";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export type DumpNode = {
|
|
86
|
+
globalId: number;
|
|
87
|
+
type: string;
|
|
88
|
+
text?: string;
|
|
89
|
+
value?: string;
|
|
90
|
+
src?: string;
|
|
91
|
+
events?: string[];
|
|
92
|
+
accessibility?: {
|
|
93
|
+
identifier?: string;
|
|
94
|
+
testID?: string;
|
|
95
|
+
nativeID?: string;
|
|
96
|
+
label?: string;
|
|
97
|
+
role?: string;
|
|
98
|
+
};
|
|
99
|
+
bounds?: { x: number; y: number; width: number; height: number };
|
|
100
|
+
style?: Record<string, unknown>;
|
|
101
|
+
children?: DumpNode[];
|
|
102
|
+
nativeListGroup?: string;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
function countWebviews(node: DumpNode): number {
|
|
106
|
+
let n = node.type === "webview" ? 1 : 0;
|
|
107
|
+
for (const child of node.children ?? []) n += countWebviews(child);
|
|
108
|
+
return n;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function firstWebviewBounds(node: DumpNode): { x: number; y: number; width: number; height: number } | null {
|
|
112
|
+
if (node.type === "webview" && node.bounds && node.bounds.width > 4 && node.bounds.height > 4) return node.bounds;
|
|
113
|
+
for (const child of node.children ?? []) {
|
|
114
|
+
const found = firstWebviewBounds(child);
|
|
115
|
+
if (found) return found;
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Poll the live capture frame until the webview region has painted content
|
|
121
|
+
// (pixel variance above a flat-background floor). A WKWebView underlay loads +
|
|
122
|
+
// renders its HTML asynchronously after the gpui tree commits — and under load
|
|
123
|
+
// that can exceed any fixed sleep — so wait for the pixels, not the clock. A
|
|
124
|
+
// genuinely-empty webview just hits the budget and proceeds (best-effort).
|
|
125
|
+
async function waitForWebviewContent(
|
|
126
|
+
capturePath: string,
|
|
127
|
+
captureTriggerPath: string,
|
|
128
|
+
bounds: { x: number; y: number; width: number; height: number },
|
|
129
|
+
windowLogicalWidth: number,
|
|
130
|
+
): Promise<void> {
|
|
131
|
+
const { readPng } = await import("../scripts/png.mjs");
|
|
132
|
+
const budgetMs = Number(process.env.RNGPUI_SHOT_WEBVIEW_BUDGET_MS) || 4000;
|
|
133
|
+
const deadline = Date.now() + budgetMs;
|
|
134
|
+
while (Date.now() < deadline) {
|
|
135
|
+
try {
|
|
136
|
+
if (existsSync(capturePath) && statSync(capturePath).size > 0) {
|
|
137
|
+
const img = (await readPng(capturePath)) as { width: number; height: number; rgba: Uint8Array };
|
|
138
|
+
const scale = img.width / windowLogicalWidth; // capture is HiDPI (≈2x)
|
|
139
|
+
const x0 = Math.max(0, Math.round((bounds.x + bounds.width * 0.15) * scale));
|
|
140
|
+
const x1 = Math.min(img.width, Math.round((bounds.x + bounds.width * 0.85) * scale));
|
|
141
|
+
const y0 = Math.max(0, Math.round((bounds.y + bounds.height * 0.1) * scale));
|
|
142
|
+
const y1 = Math.min(img.height, Math.round((bounds.y + bounds.height * 0.7) * scale));
|
|
143
|
+
// luminance variance over the region: flat (loading) ≈ 0, text content ≫ 0.
|
|
144
|
+
let sum = 0;
|
|
145
|
+
let sumSq = 0;
|
|
146
|
+
let count = 0;
|
|
147
|
+
for (let y = y0; y < y1; y += 4) {
|
|
148
|
+
for (let x = x0; x < x1; x += 4) {
|
|
149
|
+
const i = (y * img.width + x) * 4;
|
|
150
|
+
const lum = 0.299 * img.rgba[i] + 0.587 * img.rgba[i + 1] + 0.114 * img.rgba[i + 2];
|
|
151
|
+
sum += lum;
|
|
152
|
+
sumSq += lum * lum;
|
|
153
|
+
count++;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (count > 0) {
|
|
157
|
+
const variance = sumSq / count - (sum / count) ** 2;
|
|
158
|
+
if (variance > 25) return; // content present (text/edges)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
} catch {
|
|
162
|
+
/* transient read of a half-written frame — retry */
|
|
163
|
+
}
|
|
164
|
+
requestCapture(capturePath, captureTriggerPath);
|
|
165
|
+
await sleep(150);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
type SessionMeta = {
|
|
170
|
+
version: 1;
|
|
171
|
+
servicePid: number;
|
|
172
|
+
socketPath: string;
|
|
173
|
+
capturePath: string;
|
|
174
|
+
captureTriggerPath?: string;
|
|
175
|
+
bundlePath: string;
|
|
176
|
+
appName: string;
|
|
177
|
+
size: string;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
type OwnerMeta = {
|
|
181
|
+
owner?: string;
|
|
182
|
+
ownerLabel?: string;
|
|
183
|
+
appName?: string;
|
|
184
|
+
pidPath?: string;
|
|
185
|
+
controlSocketPath?: string;
|
|
186
|
+
dumpTreePath?: string;
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
type LaunchOptions = {
|
|
190
|
+
size?: string;
|
|
191
|
+
bundle?: string;
|
|
192
|
+
keep?: boolean;
|
|
193
|
+
development?: boolean;
|
|
194
|
+
appearance?: string;
|
|
195
|
+
fixture?: boolean;
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
function bundleEntry(entry: string, workdir: string, development: boolean) {
|
|
199
|
+
return bundleApp(entry, join(workdir, "app.js"), { development });
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function writeSession(dir: string, meta: SessionMeta) {
|
|
203
|
+
writeFileSync(join(dir, "session.json"), JSON.stringify(meta, null, 2));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function readSession(dir: string): SessionMeta {
|
|
207
|
+
const path = join(resolve(dir), "session.json");
|
|
208
|
+
if (!existsSync(path)) throw new Error(`session metadata not found: ${path}`);
|
|
209
|
+
return JSON.parse(readFileSync(path, "utf8")) as SessionMeta;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function assertAlive(pid: number) {
|
|
213
|
+
try {
|
|
214
|
+
process.kill(pid, 0);
|
|
215
|
+
} catch {
|
|
216
|
+
throw new Error(`rngpui service pid ${pid} is not running`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export async function launchHost(entry: string, opts: LaunchOptions = {}): Promise<LaunchedHost> {
|
|
221
|
+
const workdir = mkdtempSync(join(tmpdir(), "rngpui-cli-"));
|
|
222
|
+
const stamp = `${process.pid}-${Date.now().toString(36)}`;
|
|
223
|
+
const appName = `rngpui-cli-${stamp}`;
|
|
224
|
+
const pidPath = join(workdir, "service.pid");
|
|
225
|
+
const socketPath = join(workdir, "control.sock");
|
|
226
|
+
const capturePath = join(workdir, "frame.png");
|
|
227
|
+
const captureTriggerPath = join(workdir, "capture.request");
|
|
228
|
+
const logPath = join(workdir, "service.log");
|
|
229
|
+
const size = opts.size ?? "1280x860";
|
|
230
|
+
const [w, h] = size.split("x").map((value) => parseInt(value, 10));
|
|
231
|
+
const bundlePath = opts.bundle ? resolve(opts.bundle) : bundleEntry(entry, workdir, opts.development === true);
|
|
232
|
+
if (!existsSync(bundlePath)) throw new Error(`bundle not found: ${bundlePath}`);
|
|
233
|
+
|
|
234
|
+
const service = resolveServiceBinary().path;
|
|
235
|
+
const child = spawn(service, [], {
|
|
236
|
+
cwd: tsRoot,
|
|
237
|
+
env: {
|
|
238
|
+
...process.env,
|
|
239
|
+
...uiRuntimeEnv(service),
|
|
240
|
+
RNGPUI_BUNDLE: bundlePath,
|
|
241
|
+
RNGPUI_NO_ACTIVATE: "1",
|
|
242
|
+
RNGPUI_TEST_MODE: "1",
|
|
243
|
+
// test-mode services exit when their spawning parent dies (the orphan
|
|
244
|
+
// watchdog in service.rs). A --keep launch is the one flow whose
|
|
245
|
+
// CONTRACT is to outlive this cli process (do/get --session ... then
|
|
246
|
+
// `rngpui close`), so it opts out explicitly.
|
|
247
|
+
...(opts.keep === true ? { RNGPUI_KEEP_ALIVE: "1" } : {}),
|
|
248
|
+
RNGPUI_CAPTURE_ONSCREEN: "1",
|
|
249
|
+
RNGPUI_OPAQUE_WINDOW: "1",
|
|
250
|
+
RNGPUI_CAPTURE_ALPHA: process.env.RNGPUI_CAPTURE_ALPHA || "0.2",
|
|
251
|
+
RNGPUI_WINDOW_SIZE: `${w},${h}`,
|
|
252
|
+
RNGPUI_CAPTURE_PNG: capturePath,
|
|
253
|
+
RNGPUI_CAPTURE_TRIGGER: captureTriggerPath,
|
|
254
|
+
RNGPUI_SERVICE_PID_FILE: pidPath,
|
|
255
|
+
RNGPUI_CONTROL_SOCKET: socketPath,
|
|
256
|
+
RNGPUI_APP_NAME: appName,
|
|
257
|
+
...(opts.appearance ? { RNGPUI_FORCE_APPEARANCE: opts.appearance } : {}),
|
|
258
|
+
...(opts.appearance ? { TM_CAPTURE_APPEARANCE: opts.appearance } : {}),
|
|
259
|
+
...(opts.fixture === true ? { TM_FIXTURE_ONLY: "1" } : {}),
|
|
260
|
+
},
|
|
261
|
+
// stderr goes to a session log file, never a pipe back to this process: the
|
|
262
|
+
// service outlives the cli (dev/--keep), and an eprintln! into a closed pipe
|
|
263
|
+
// panics (EPIPE) and aborts the whole service mid-demo.
|
|
264
|
+
stdio: ["ignore", "ignore", openSync(logPath, "a")],
|
|
265
|
+
});
|
|
266
|
+
const logTail = () => {
|
|
267
|
+
try {
|
|
268
|
+
return readFileSync(logPath, "utf8").split("\n").slice(-30).join("\n");
|
|
269
|
+
} catch {
|
|
270
|
+
return "(no service log)";
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
const fail = (message: string): never => {
|
|
275
|
+
killPidFile(pidPath);
|
|
276
|
+
try {
|
|
277
|
+
child.kill("SIGTERM");
|
|
278
|
+
} catch {
|
|
279
|
+
/* already gone */
|
|
280
|
+
}
|
|
281
|
+
// deliberately NOT rmSync(workdir) here. This is the ERROR path, and the
|
|
282
|
+
// service log inside the workdir is the only evidence of why it failed —
|
|
283
|
+
// but rmSync ran before the template below evaluated logTail(), so every
|
|
284
|
+
// failure printed "(no service log)" and deleted the one thing worth
|
|
285
|
+
// reading. Two agents lost real time to a window bug that way. A leaked
|
|
286
|
+
// temp dir is cheap; the log is not, so keep it and say where it is.
|
|
287
|
+
throw new Error(`${message}\n--- service log (${logPath}) ---\n${logTail()}`);
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
let servicePid = 0;
|
|
291
|
+
try {
|
|
292
|
+
servicePid = await waitForServicePid(pidPath, {
|
|
293
|
+
timeoutMs: 20_000,
|
|
294
|
+
isFixtureExited: () => child.exitCode != null,
|
|
295
|
+
});
|
|
296
|
+
} catch (error) {
|
|
297
|
+
fail(`service did not start: ${(error as Error).message}`);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
let window: GpuiWindow;
|
|
301
|
+
try {
|
|
302
|
+
window = (await waitForWindow((win: GpuiWindow) => win.pid === servicePid, {
|
|
303
|
+
timeoutMs: 15_000,
|
|
304
|
+
isFixtureExited: () => child.exitCode != null,
|
|
305
|
+
// the service writing anything is progress, so a starved launch keeps
|
|
306
|
+
// its budget instead of dying at a fixed 15s that only suited an idle box.
|
|
307
|
+
progress: () => {
|
|
308
|
+
try {
|
|
309
|
+
return statSync(logPath).size;
|
|
310
|
+
} catch {
|
|
311
|
+
return -1;
|
|
312
|
+
}
|
|
313
|
+
},
|
|
314
|
+
})) as GpuiWindow;
|
|
315
|
+
} catch (error) {
|
|
316
|
+
const finalWindow = currentWindow(servicePid);
|
|
317
|
+
if (finalWindow) {
|
|
318
|
+
window = finalWindow;
|
|
319
|
+
} else {
|
|
320
|
+
const seen = (listWindows() as GpuiWindow[])
|
|
321
|
+
.map((win) => `${win.owner}/"${win.title}"/pid${win.pid}/${win.width}x${win.height}`)
|
|
322
|
+
.join(" ");
|
|
323
|
+
fail(`window did not appear: ${(error as Error).message} [wanted pid ${servicePid}; saw: ${seen}]`);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
await waitForSocket(socketPath, () => child.exitCode != null);
|
|
328
|
+
// settle before the first capture. 500ms covers a pure-Metal tree, but a
|
|
329
|
+
// WKWebView underlay (the team-machine chat/timeline stage) loads + renders its
|
|
330
|
+
// HTML async AFTER the gpui tree commits — and under machine load that can
|
|
331
|
+
// take well over a second, so a fixed sleep races it (the blank-webview bug).
|
|
332
|
+
// Instead: find the webview's bounds, then poll the live capture frame until
|
|
333
|
+
// that region actually has painted content (variance above the background
|
|
334
|
+
// floor), with a budget. A genuinely-empty webview just hits the budget.
|
|
335
|
+
if (!opts.development) {
|
|
336
|
+
await sleep(Number(process.env.RNGPUI_SHOT_SETTLE_MS) || 350);
|
|
337
|
+
try {
|
|
338
|
+
const probe = await requestSocket<{ ok: boolean; tree?: DumpNode }>(socketPath, { $cmd: "dump" });
|
|
339
|
+
const wv = probe.tree ? firstWebviewBounds(probe.tree) : null;
|
|
340
|
+
if (wv) {
|
|
341
|
+
requestCapture(capturePath, captureTriggerPath);
|
|
342
|
+
await waitForWebviewContent(capturePath, captureTriggerPath, wv, w);
|
|
343
|
+
}
|
|
344
|
+
} catch {
|
|
345
|
+
/* probe is best-effort; proceed to capture */
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const meta: SessionMeta = {
|
|
350
|
+
version: 1,
|
|
351
|
+
servicePid,
|
|
352
|
+
socketPath,
|
|
353
|
+
capturePath,
|
|
354
|
+
captureTriggerPath,
|
|
355
|
+
bundlePath,
|
|
356
|
+
appName,
|
|
357
|
+
size,
|
|
358
|
+
};
|
|
359
|
+
writeSession(workdir, meta);
|
|
360
|
+
return makeLaunchedHost({
|
|
361
|
+
child,
|
|
362
|
+
meta,
|
|
363
|
+
sessionDir: workdir,
|
|
364
|
+
window,
|
|
365
|
+
ownsProcess: true,
|
|
366
|
+
keepOnClose: opts.keep === true,
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
export async function attachSession(sessionDir: string): Promise<LaunchedHost> {
|
|
371
|
+
const meta = readSession(sessionDir);
|
|
372
|
+
assertAlive(meta.servicePid);
|
|
373
|
+
await waitForSocket(meta.socketPath);
|
|
374
|
+
const window = (currentWindow(meta.servicePid) ??
|
|
375
|
+
((await waitForWindow((win: GpuiWindow) => win.pid === meta.servicePid, {
|
|
376
|
+
timeoutMs: 5_000,
|
|
377
|
+
})) as GpuiWindow)) as GpuiWindow;
|
|
378
|
+
return makeLaunchedHost({
|
|
379
|
+
child: null,
|
|
380
|
+
meta,
|
|
381
|
+
sessionDir: resolve(sessionDir),
|
|
382
|
+
window,
|
|
383
|
+
ownsProcess: false,
|
|
384
|
+
keepOnClose: true,
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
export function closeSession(sessionDir: string) {
|
|
389
|
+
const dir = resolve(sessionDir);
|
|
390
|
+
const meta = readSession(dir);
|
|
391
|
+
killPid(meta.servicePid);
|
|
392
|
+
rmSync(dir, { recursive: true, force: true });
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function makeLaunchedHost({
|
|
396
|
+
child,
|
|
397
|
+
meta,
|
|
398
|
+
sessionDir,
|
|
399
|
+
window,
|
|
400
|
+
ownsProcess,
|
|
401
|
+
keepOnClose,
|
|
402
|
+
}: {
|
|
403
|
+
child: ChildProcess | null;
|
|
404
|
+
meta: SessionMeta;
|
|
405
|
+
sessionDir: string;
|
|
406
|
+
window: GpuiWindow;
|
|
407
|
+
ownsProcess: boolean;
|
|
408
|
+
keepOnClose: boolean;
|
|
409
|
+
}): LaunchedHost {
|
|
410
|
+
return {
|
|
411
|
+
mode: "launch",
|
|
412
|
+
pid: child?.pid ?? meta.servicePid,
|
|
413
|
+
servicePid: meta.servicePid,
|
|
414
|
+
window,
|
|
415
|
+
sessionDir,
|
|
416
|
+
appName: meta.appName,
|
|
417
|
+
controlSocketPath: meta.socketPath,
|
|
418
|
+
request<T = unknown>(cmd: object) {
|
|
419
|
+
return requestSocket<T>(meta.socketPath, cmd);
|
|
420
|
+
},
|
|
421
|
+
async dump() {
|
|
422
|
+
const response = await requestSocket<{ ok: boolean; tree?: DumpNode; error?: string }>(meta.socketPath, { $cmd: "dump" });
|
|
423
|
+
if (!response.ok || !response.tree) throw new Error(response.error || "dump failed");
|
|
424
|
+
return response.tree;
|
|
425
|
+
},
|
|
426
|
+
capture(path: string) {
|
|
427
|
+
if (meta.captureTriggerPath) {
|
|
428
|
+
requestCapture(meta.capturePath, meta.captureTriggerPath);
|
|
429
|
+
}
|
|
430
|
+
waitForCapture(meta.capturePath, path);
|
|
431
|
+
},
|
|
432
|
+
close() {
|
|
433
|
+
if (keepOnClose) return;
|
|
434
|
+
if (ownsProcess) killPid(meta.servicePid);
|
|
435
|
+
try {
|
|
436
|
+
child?.kill("SIGTERM");
|
|
437
|
+
} catch {
|
|
438
|
+
/* already gone */
|
|
439
|
+
}
|
|
440
|
+
rmSync(sessionDir, { recursive: true, force: true });
|
|
441
|
+
},
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
export async function attachHost(): Promise<AttachedHost> {
|
|
446
|
+
const candidates = (listWindows() as GpuiWindow[])
|
|
447
|
+
.filter((window) => window.title === "react-native-gpui" || window.rngpui === true)
|
|
448
|
+
.filter((window) => !(window as { secondary?: boolean }).secondary)
|
|
449
|
+
.map((window) => ({ window, meta: ownerMetaForWindow(window) }))
|
|
450
|
+
.sort((a, b) => {
|
|
451
|
+
const ad = isMetaDriveable(a.meta) ? 0 : 1;
|
|
452
|
+
const bd = isMetaDriveable(b.meta) ? 0 : 1;
|
|
453
|
+
if (ad !== bd) return ad - bd;
|
|
454
|
+
return b.window.width * b.window.height - a.window.width * a.window.height;
|
|
455
|
+
});
|
|
456
|
+
if (candidates.length === 0) {
|
|
457
|
+
throw new Error("no running rngpui window found; launch one with --launch <entry.tsx> or --bundle <app.js>");
|
|
458
|
+
}
|
|
459
|
+
const { window, meta } = candidates[0];
|
|
460
|
+
const controlSocketPath = isMetaDriveable(meta) ? meta.controlSocketPath : undefined;
|
|
461
|
+
const request = controlSocketPath ? <T = unknown>(cmd: object) => requestSocket<T>(controlSocketPath, cmd) : undefined;
|
|
462
|
+
const attached: AttachedHost = {
|
|
463
|
+
mode: "attach",
|
|
464
|
+
servicePid: window.pid,
|
|
465
|
+
window,
|
|
466
|
+
appName: meta?.appName ?? window.owner,
|
|
467
|
+
controlSocketPath,
|
|
468
|
+
dumpTreePath: meta?.dumpTreePath,
|
|
469
|
+
capture(path: string) {
|
|
470
|
+
const fresh = currentWindow(window.pid) ?? window;
|
|
471
|
+
captureWindow(fresh, path);
|
|
472
|
+
},
|
|
473
|
+
async dump() {
|
|
474
|
+
if (request) {
|
|
475
|
+
const response = await request<{ ok: boolean; tree?: DumpNode; error?: string }>({ $cmd: "dump" });
|
|
476
|
+
if (!response.ok || !response.tree) throw new Error(response.error || "dump failed");
|
|
477
|
+
return response.tree;
|
|
478
|
+
}
|
|
479
|
+
return null;
|
|
480
|
+
},
|
|
481
|
+
close() {
|
|
482
|
+
/* read-only: never touch a process we do not own */
|
|
483
|
+
},
|
|
484
|
+
};
|
|
485
|
+
if (request) attached.request = request;
|
|
486
|
+
return attached;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
export function currentWindow(servicePid: number): GpuiWindow | null {
|
|
490
|
+
return (
|
|
491
|
+
(listWindows() as GpuiWindow[])
|
|
492
|
+
.filter((window) => window.pid === servicePid && !(window as { secondary?: boolean }).secondary)
|
|
493
|
+
.sort((a, b) => b.width * b.height - a.width * a.height)[0] ?? null
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
export type ServiceWindow = {
|
|
498
|
+
id: number;
|
|
499
|
+
kind: "main" | "normal" | "dialog" | "popup";
|
|
500
|
+
title: string | null;
|
|
501
|
+
testID: string | null;
|
|
502
|
+
nodes: number;
|
|
503
|
+
framesPainted: number;
|
|
504
|
+
focusedInput: number | null;
|
|
505
|
+
bounds: { x: number; y: number; outerWidth: number; outerHeight: number; width: number; height: number; active: boolean } | null;
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
export async function listServiceWindows(host: Host): Promise<ServiceWindow[]> {
|
|
509
|
+
if (!isDriveableHost(host)) throw new Error("listing windows needs a driveable target (control socket)");
|
|
510
|
+
const listing = await host.request<{ ok: boolean; windows?: ServiceWindow[]; error?: string }>({ $cmd: "windows" });
|
|
511
|
+
if (!listing.ok || !listing.windows) throw new Error(listing.error || "windows listing failed");
|
|
512
|
+
return listing.windows;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
export function matchServiceWindow(windows: ServiceWindow[], selector: string): ServiceWindow | null {
|
|
516
|
+
const wanted = selector.trim();
|
|
517
|
+
if (wanted === "main") return windows.find((window) => window.id === 0) ?? null;
|
|
518
|
+
if (/^\d+$/.test(wanted)) return windows.find((window) => window.id === Number(wanted)) ?? null;
|
|
519
|
+
const lower = wanted.toLowerCase();
|
|
520
|
+
return (
|
|
521
|
+
windows.find((window) => window.testID === wanted) ??
|
|
522
|
+
windows.find((window) => window.kind === lower && window.id !== 0) ??
|
|
523
|
+
windows.find((window) => (window.title ?? "").toLowerCase().includes(lower)) ??
|
|
524
|
+
null
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const GLOBAL_COMMANDS = new Set(["windows", "frameStats", "rendererProvenance", "traceStart", "traceStop", "evalJs", "hotEval"]);
|
|
529
|
+
|
|
530
|
+
export async function scopeToWindow<T extends Host>(host: T, selector: string): Promise<T> {
|
|
531
|
+
const windows = await listServiceWindows(host);
|
|
532
|
+
const target = matchServiceWindow(windows, selector);
|
|
533
|
+
if (!target) {
|
|
534
|
+
const known = windows.map((window) => `${window.id}:${window.kind}${window.testID ? `:${window.testID}` : ""}`).join(" ");
|
|
535
|
+
throw new Error(`no window matches "${selector}" (windows: ${known})`);
|
|
536
|
+
}
|
|
537
|
+
if (target.id === 0) return host;
|
|
538
|
+
const request = host.request!.bind(host);
|
|
539
|
+
const scopedRequest = <R = unknown>(cmd: object) => {
|
|
540
|
+
const name = (cmd as { $cmd?: string }).$cmd ?? "";
|
|
541
|
+
return request<R>(GLOBAL_COMMANDS.has(name) ? cmd : { ...cmd, window: target.id });
|
|
542
|
+
};
|
|
543
|
+
const bounds = target.bounds;
|
|
544
|
+
const window: GpuiWindow = {
|
|
545
|
+
...host.window,
|
|
546
|
+
title: target.title ?? host.window.title,
|
|
547
|
+
x: bounds?.x ?? 0,
|
|
548
|
+
y: bounds?.y ?? 0,
|
|
549
|
+
width: bounds?.outerWidth ?? host.window.width,
|
|
550
|
+
height: bounds?.outerHeight ?? host.window.height,
|
|
551
|
+
bounds: { x: bounds?.x ?? 0, y: bounds?.y ?? 0, width: bounds?.outerWidth ?? 0, height: bounds?.outerHeight ?? 0 },
|
|
552
|
+
};
|
|
553
|
+
const scoped = {
|
|
554
|
+
...host,
|
|
555
|
+
window,
|
|
556
|
+
request: scopedRequest,
|
|
557
|
+
async dump() {
|
|
558
|
+
const response = await scopedRequest<{ ok: boolean; tree?: DumpNode; error?: string }>({ $cmd: "dump" });
|
|
559
|
+
if (!response.ok || !response.tree) throw new Error(response.error || "dump failed");
|
|
560
|
+
return response.tree;
|
|
561
|
+
},
|
|
562
|
+
capture(path: string) {
|
|
563
|
+
captureServiceWindow(host, target.id, path);
|
|
564
|
+
},
|
|
565
|
+
close: () => host.close(),
|
|
566
|
+
};
|
|
567
|
+
return scoped as T;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function captureServiceWindow(host: Host, windowId: number, path: string) {
|
|
571
|
+
if (host.mode === "launch") {
|
|
572
|
+
const meta = readSession(host.sessionDir);
|
|
573
|
+
if (!meta.captureTriggerPath) throw new Error("this session has no capture trigger");
|
|
574
|
+
rmSync(meta.capturePath, { force: true });
|
|
575
|
+
writeFileSync(meta.captureTriggerPath, `capture window=${windowId}\n`);
|
|
576
|
+
waitForCapture(meta.capturePath, path);
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
const record = (listWindows() as Array<GpuiWindow & { windowId?: number }>).find(
|
|
580
|
+
(window) => window.pid === host.servicePid && window.windowId === windowId,
|
|
581
|
+
);
|
|
582
|
+
if (!record) throw new Error(`window ${windowId} of pid ${host.servicePid} is not in the window registry`);
|
|
583
|
+
captureWindow(record, path);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function requestSocket<T>(socketPath: string, cmd: object): Promise<T> {
|
|
587
|
+
return new Promise((resolve, reject) => {
|
|
588
|
+
const socket = createConnection(socketPath);
|
|
589
|
+
let buffer = "";
|
|
590
|
+
const timer = setTimeout(() => {
|
|
591
|
+
socket.destroy();
|
|
592
|
+
reject(new Error(`debug command timed out on ${socketPath}`));
|
|
593
|
+
}, 10_000);
|
|
594
|
+
socket.on("connect", () => {
|
|
595
|
+
socket.write(JSON.stringify(cmd) + "\n");
|
|
596
|
+
});
|
|
597
|
+
socket.on("data", (chunk) => {
|
|
598
|
+
buffer += chunk.toString("utf8");
|
|
599
|
+
const idx = buffer.indexOf("\n");
|
|
600
|
+
if (idx >= 0) {
|
|
601
|
+
clearTimeout(timer);
|
|
602
|
+
socket.end();
|
|
603
|
+
try {
|
|
604
|
+
resolve(JSON.parse(buffer.slice(0, idx)) as T);
|
|
605
|
+
} catch (error) {
|
|
606
|
+
reject(error);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
});
|
|
610
|
+
socket.on("error", (error) => {
|
|
611
|
+
clearTimeout(timer);
|
|
612
|
+
reject(error);
|
|
613
|
+
});
|
|
614
|
+
socket.on("end", () => {
|
|
615
|
+
if (!buffer.trim()) {
|
|
616
|
+
clearTimeout(timer);
|
|
617
|
+
reject(new Error("debug socket closed without a response"));
|
|
618
|
+
}
|
|
619
|
+
});
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function isMetaDriveable(meta: OwnerMeta | null): meta is OwnerMeta & { controlSocketPath: string } {
|
|
624
|
+
return !!meta?.controlSocketPath && existsSync(meta.controlSocketPath);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function ownerMetaForWindow(window: GpuiWindow): OwnerMeta | null {
|
|
628
|
+
const paths = new Set<string>([`/tmp/${window.owner}.owner.json`]);
|
|
629
|
+
try {
|
|
630
|
+
for (const entry of readdirSync("/tmp")) {
|
|
631
|
+
if (entry.endsWith(".owner.json")) paths.add(join("/tmp", entry));
|
|
632
|
+
}
|
|
633
|
+
} catch {
|
|
634
|
+
/* /tmp should exist, but attach can still work read-only without metadata */
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
for (const path of paths) {
|
|
638
|
+
const meta = readOwnerMeta(path);
|
|
639
|
+
if (!meta) continue;
|
|
640
|
+
if (meta.appName === window.owner) return meta;
|
|
641
|
+
if (meta.pidPath && pidPathMatches(meta.pidPath, window.pid)) return meta;
|
|
642
|
+
}
|
|
643
|
+
return null;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function readOwnerMeta(path: string): OwnerMeta | null {
|
|
647
|
+
try {
|
|
648
|
+
return JSON.parse(readFileSync(path, "utf8")) as OwnerMeta;
|
|
649
|
+
} catch {
|
|
650
|
+
return null;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function pidPathMatches(path: string, pid: number): boolean {
|
|
655
|
+
try {
|
|
656
|
+
return Number(readFileSync(path, "utf8").trim()) === pid;
|
|
657
|
+
} catch {
|
|
658
|
+
return false;
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
async function waitForSocket(path: string, exited?: () => boolean) {
|
|
663
|
+
const deadline = Date.now() + 8_000;
|
|
664
|
+
while (Date.now() < deadline) {
|
|
665
|
+
if (existsSync(path)) return;
|
|
666
|
+
if (exited?.()) break;
|
|
667
|
+
await sleep(25);
|
|
668
|
+
}
|
|
669
|
+
throw new Error(`timed out waiting for control socket at ${path}`);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function waitForCapture(source: string, target: string) {
|
|
673
|
+
const deadline = Date.now() + 4_000;
|
|
674
|
+
while (Date.now() < deadline) {
|
|
675
|
+
if (existsSync(source) && statSync(source).size > 0) {
|
|
676
|
+
execSyncSleep(300);
|
|
677
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
678
|
+
copyFileSync(source, target);
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
execSyncSleep(120);
|
|
682
|
+
}
|
|
683
|
+
throw new Error("in-service capture produced no frame (RNGPUI_CAPTURE_PNG)");
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function requestCapture(capturePath: string, triggerPath: string) {
|
|
687
|
+
rmSync(capturePath, { force: true });
|
|
688
|
+
writeFileSync(triggerPath, "capture\n");
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function execSyncSleep(ms: number) {
|
|
692
|
+
try {
|
|
693
|
+
execFileSync("sleep", [String(ms / 1000)]);
|
|
694
|
+
} catch {
|
|
695
|
+
/* ignore */
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function killPidFile(path: string) {
|
|
700
|
+
try {
|
|
701
|
+
const pid = Number(readFileSync(path, "utf8").trim());
|
|
702
|
+
if (Number.isFinite(pid) && pid > 0) killPid(pid);
|
|
703
|
+
} catch {
|
|
704
|
+
/* no pid yet */
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function killPid(pid: number) {
|
|
709
|
+
try {
|
|
710
|
+
process.kill(pid, "SIGTERM");
|
|
711
|
+
} catch {
|
|
712
|
+
/* already gone */
|
|
713
|
+
}
|
|
714
|
+
}
|