feat: add native devtools iOS — view hierarchy, network inspection, passive simulator watcher

- Adds native-devtools-ios package with pre-built dylibs (libNativeDevtoolsIos, libKeyboardPatch, libInjectionBootstrap)
- Passive simulator watcher injects dylibs into all booted simulators on tool-server startup, eliminating manual restart requirement
- Lazy network activation: NSURLProtocol interception is opt-in per bundleId via Control socket command, re-activated on reconnect
- New tools: native-find-views, native-full-hierarchy, native-network-logs, native-devtools-status
- dev script: non-fatal argent-private submodule (uses pre-built dylibs when source unavailable)
- Adds Prettier config and format script
- Updates CONTRIBUTING.md with npm run dev workflow and project structure

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Filip131311
2026-04-03 14:49:12 +02:00
parent d4b4ebfdee
commit 47b66b3eed
21 changed files with 909 additions and 33 deletions
+13
View File
@@ -0,0 +1,13 @@
{
"printWidth": 100,
"tabWidth": 2,
"useTabs": false,
"semi": true,
"singleQuote": false,
"quoteProps": "consistent",
"jsxSingleQuote": false,
"trailingComma": "es5",
"bracketSpacing": true,
"bracketSameLine": true,
"arrowParens": "always"
}
+30 -1
View File
@@ -38,6 +38,22 @@ Thank you for your interest in contributing to Argent! This guide covers everyth
npm install
```
3. **Start the dev environment:**
```bash
npm run dev
```
This builds the native devtools dylibs (if the private submodule is available, otherwise uses pre-built binaries), compiles the MCP TypeScript, patches `~/.claude.json` to point at the local MCP, and starts the tool-server from source via `ts-node`. Press `Ctrl+C` to stop — the script automatically restores your global Argent configuration.
To use a different port:
```bash
PORT=4000 npm run dev
```
> **Note:** `packages/argent-private` is a private git submodule that holds the ObjC source for the native devtools dylibs. If you don't have SSH access to it, `npm run dev` will use the pre-built dylibs committed to the repository — everything else works normally.
That's it — no separate install steps per package are needed.
---
@@ -52,6 +68,7 @@ This is an npm workspaces monorepo. All packages live under `packages/`:
| `@argent/tool-server` | `packages/tool-server` | HTTP API over the registry (port 3001). Registers all blueprints and tools |
| `@software-mansion/argent` | `packages/mcp` | MCP bridge — exposes tools to AI assistants via Model Context Protocol |
| `@argent/skills` | `packages/skills` | Markdown skill files (prefixed `argent-*`) that instruct AI agents how to use Argent tools |
| `@argent/native-devtools-ios` | `packages/native-devtools-ios` | Pre-built dylibs for iOS simulator injection (view hierarchy, network inspection). ObjC source lives in `packages/argent-private` _(private submodule)_ |
The `tsconfig.json` at the root uses TypeScript project references; `tsconfig.base.json` holds shared compiler options (`strict`, `ES2022`, etc.).
@@ -59,6 +76,16 @@ The `tsconfig.json` at the root uses TypeScript project references; `tsconfig.ba
## Building
### Local development (recommended)
```bash
npm run dev
```
Builds everything and starts the tool-server from source. See [Setting up the dev environment](#setting-up-the-dev-environment) for details.
### Full build
Build all packages at once using TypeScript project references:
```bash
@@ -84,7 +111,9 @@ npm run pack:mcp
## Running the project
**Start the tools server:**
For day-to-day development, use `npm run dev` (see [Setting up the dev environment](#setting-up-the-dev-environment)).
**Production-like start** (builds bundles first, then starts from compiled output):
```bash
npm run start
+36 -4
View File
@@ -7,7 +7,14 @@
"name": "argent-workspace",
"workspaces": [
"packages/*"
]
],
"devDependencies": {
"prettier": "^3.0.0"
}
},
"node_modules/@argent/native-devtools-ios": {
"resolved": "packages/native-devtools-ios",
"link": true
},
"node_modules/@argent/registry": {
"resolved": "packages/registry",
@@ -2258,6 +2265,22 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/prettier": {
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"license": "MIT",
@@ -4231,9 +4254,17 @@
"vitest": "^4.0.18"
}
},
"packages/native-devtools-ios": {
"name": "@argent/native-devtools-ios",
"version": "1.0.0",
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.5.0"
}
},
"packages/registry": {
"name": "@argent/registry",
"version": "0.3.0",
"version": "0.3.3",
"dependencies": {
"zod": "^3.23.0"
},
@@ -4945,15 +4976,16 @@
},
"packages/skills": {
"name": "@argent/skills",
"version": "0.3.0",
"version": "0.3.3",
"bin": {
"argent-skills": "scripts/install.js"
}
},
"packages/tool-server": {
"name": "@argent/tool-server",
"version": "0.3.0",
"version": "0.3.3",
"dependencies": {
"@argent/native-devtools-ios": "file:../native-devtools-ios",
"@argent/registry": "file:../registry",
"@clack/prompts": "^1.1.0",
"express": "^4.19.2",
+5 -1
View File
@@ -7,6 +7,10 @@
"start": "npm run build -w @argent/registry && npm run dev -w @argent/tool-server",
"start:tool-server": "npm run build -w @argent/registry && npm run dev -w @argent/tool-server",
"pack:mcp": "npm run build -w @software-mansion/argent && npm pack -w @software-mansion/argent --pack-destination .",
"dev": "node scripts/dev.cjs"
"dev": "node scripts/dev.cjs",
"format": "prettier --write ."
},
"devDependencies": {
"prettier": "^3.0.0"
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
+15
View File
@@ -0,0 +1,15 @@
{
"name": "@argent/native-devtools-ios",
"version": "1.0.0",
"private": true,
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "rm -rf dist tsconfig.tsbuildinfo && tsc",
"build:dylibs": "bash scripts/build.sh"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.5.0"
}
}
+24
View File
@@ -0,0 +1,24 @@
import * as path from "node:path";
import * as fs from "node:fs";
// When bundled by esbuild, __dirname points into dist/.
// ARGENT_NATIVE_DEVTOOLS_DIR lets the launcher override the dylib directory,
// matching the same pattern used by ARGENT_SIMULATOR_SERVER_DIR.
const DYLIB_DIR =
process.env.ARGENT_NATIVE_DEVTOOLS_DIR ??
path.join(__dirname, "..", "dylibs");
function requireDylib(name: string): string {
const p = path.join(DYLIB_DIR, name);
if (!fs.existsSync(p)) {
throw new Error(`Native devtools dylib not found: ${p}`);
}
return p;
}
export const bootstrapDylibPath = () =>
requireDylib("libInjectionBootstrap.dylib");
export const nativeDevtoolsDylibPath = () =>
requireDylib("libNativeDevtoolsIos.dylib");
export const keyboardPatchDylibPath = () =>
requireDylib("libKeyboardPatch.dylib");
@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"declaration": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
+1
View File
@@ -12,6 +12,7 @@
},
"dependencies": {
"@argent/registry": "file:../registry",
"@argent/native-devtools-ios": "file:../native-devtools-ios",
"@clack/prompts": "^1.1.0",
"express": "^4.19.2",
"source-map-js": "^1.2.1",
@@ -0,0 +1,312 @@
import * as net from "node:net";
import * as fs from "node:fs";
import * as readline from "node:readline";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import {
TypedEventEmitter,
type ServiceBlueprint,
type ServiceEvents,
} from "@argent/registry";
import { bootstrapDylibPath } from "@argent/native-devtools-ios";
const execFileAsync = promisify(execFile);
export const NATIVE_DEVTOOLS_NAMESPACE = "NativeDevtools";
export interface NetworkEvent {
method: string;
params: unknown;
timestamp: number;
}
export type ViewInspectorMethod =
| "ViewHierarchy.getFullHierarchy"
| "ViewHierarchy.findViews"
| "ViewHierarchy.viewAtPoint"
| "ViewHierarchy.userInteractableViewAtPoint"
| "ViewHierarchy.describeScreen";
export interface NativeDevtoolsApi {
// Simulator-level
isEnvSetup(): boolean;
readonly socketPath: string;
// App-level — all keyed by bundleId
isConnected(bundleId: string): boolean;
/**
* Returns true if the app needs to be restarted before native features are available.
* Async because when not connected it re-verifies and re-sets the launchd env —
* this handles the simulator-reboot case where DYLD_INSERT_LIBRARIES was silently cleared.
*/
requiresAppRestart(bundleId: string): Promise<boolean>;
/**
* Activates NSURLProtocol network interception for a specific app.
* Idempotent — safe to call multiple times. Sticky: if the app is killed
* and relaunched, network inspection is automatically re-enabled on reconnect.
*/
activateNetworkInspection(bundleId: string): void;
getNetworkLog(bundleId: string): NetworkEvent[];
clearNetworkLog(bundleId: string): void;
queryViewHierarchy(
bundleId: string,
method: ViewInspectorMethod,
params?: Record<string, unknown>
): Promise<unknown>;
}
interface AppConnection {
socket: net.Socket;
networkLog: NetworkEvent[];
}
function getNativeDevtoolsSocketPath(udid: string): string {
// Deterministic, short — well under the 104-char macOS Unix socket limit
// /tmp/argent-nd-XXXXXXXX.sock = 28 chars
return `/tmp/argent-nd-${udid.slice(0, 8)}.sock`;
}
async function ensureEnv(udid: string, socketPath: string): Promise<void> {
const bootstrapPath = bootstrapDylibPath();
// xcrun simctl getenv exits non-zero when the var is unset — suppress rejection
const result = await execFileAsync(
"xcrun",
["simctl", "getenv", udid, "DYLD_INSERT_LIBRARIES"],
{ encoding: "utf8" }
).catch((e) => ({ stdout: (e as NodeJS.ErrnoException & { stdout?: string }).stdout ?? "" }));
const existing = (result.stdout ?? "").trim();
const entries = existing ? existing.split(":") : [];
if (!entries.includes(bootstrapPath)) {
const updated = [...entries, bootstrapPath].join(":");
await execFileAsync("xcrun", [
"simctl",
"spawn",
udid,
"launchctl",
"setenv",
"DYLD_INSERT_LIBRARIES",
updated,
]);
}
// Always re-set the socket path — deterministic value, cheap no-op if already correct,
// ensures correctness after tool-server restarts.
await execFileAsync("xcrun", [
"simctl",
"spawn",
udid,
"launchctl",
"setenv",
"NATIVE_DEVTOOLS_IOS_CDP_SOCKET",
socketPath,
]);
}
export const nativeDevtoolsBlueprint: ServiceBlueprint<
NativeDevtoolsApi,
string
> = {
namespace: NATIVE_DEVTOOLS_NAMESPACE,
getURN(udid: string) {
return `${NATIVE_DEVTOOLS_NAMESPACE}:${udid}`;
},
async factory(_deps, udid) {
const socketPath = getNativeDevtoolsSocketPath(udid);
const MAX_LOG_ENTRIES = 1000;
const connections = new Map<string, AppConnection>();
const pendingRpc = new Map<
number,
{ resolve: (v: unknown) => void; reject: (e: Error) => void }
>();
let nextRpcId = 1;
let envSetup = false;
const activatedBundleIds = new Set<string>();
const events = new TypedEventEmitter<ServiceEvents>();
// Remove stale socket file from a crashed previous run
try {
fs.unlinkSync(socketPath);
} catch {}
// ── Socket server ─────────────────────────────────────────────────────────
const server = net.createServer((socket) => {
let bundleId: string | null = null;
const rl = readline.createInterface({ input: socket });
rl.on("line", (raw) => {
let msg: { type: string; payload: any };
try {
msg = JSON.parse(raw);
} catch {
return;
}
// ── Handshake (must be first message) ──
if (bundleId === null) {
if (msg.type !== "Control") return;
bundleId = msg.payload.bundleId as string;
// If the same app reconnects (e.g. fast restart), close the old socket
const existing = connections.get(bundleId);
if (existing) {
existing.socket.destroy();
}
connections.set(bundleId, { socket, networkLog: [] });
// Re-activate network inspection if it was previously enabled for this app
if (activatedBundleIds.has(bundleId)) {
socket.write(
JSON.stringify({
type: "Control",
payload: { command: "activateNetworkInspection" },
}) + "\n"
);
}
return;
}
// ── CDP Network.* events ──
if (msg.type === "CDP") {
const p = msg.payload;
// Unsolicited events have method but no id
if (p.method && p.id === undefined) {
const conn = connections.get(bundleId);
if (conn) {
if (conn.networkLog.length >= MAX_LOG_ENTRIES) {
conn.networkLog.shift();
}
conn.networkLog.push({
method: p.method,
params: p.params,
timestamp: Date.now(),
});
}
}
}
// ── ViewInspector RPC responses ──
if (msg.type === "ViewInspector") {
const p = msg.payload;
const pending = pendingRpc.get(p.id);
if (!pending) return;
pendingRpc.delete(p.id);
if (p.error) pending.reject(new Error(p.error.message));
else pending.resolve(p.result);
}
});
socket.on("close", () => {
rl.close();
if (bundleId !== null) {
// Only delete if this socket is still the active one —
// a fast reconnect may have already replaced it
if (connections.get(bundleId)?.socket === socket) {
connections.delete(bundleId);
}
}
});
socket.on("error", () => {
// errors are handled via the close event
});
});
server.listen(socketPath);
// ── ensureEnv — runs once at factory init ─────────────────────────────────
await ensureEnv(udid, socketPath);
envSetup = true;
// ── Public API ────────────────────────────────────────────────────────────
const api: NativeDevtoolsApi = {
isEnvSetup: () => envSetup,
socketPath,
isConnected: (bundleId) => connections.has(bundleId),
async requiresAppRestart(bundleId) {
if (connections.has(bundleId)) return false;
// Re-verify and re-set env — handles the case where the simulator was
// rebooted and launchd cleared DYLD_INSERT_LIBRARIES
await ensureEnv(udid, socketPath);
return true;
},
activateNetworkInspection(bundleId) {
activatedBundleIds.add(bundleId);
const conn = connections.get(bundleId);
if (conn) {
conn.socket.write(
JSON.stringify({
type: "Control",
payload: { command: "activateNetworkInspection" },
}) + "\n"
);
}
},
getNetworkLog: (bundleId) => [
...(connections.get(bundleId)?.networkLog ?? []),
],
clearNetworkLog: (bundleId) => {
const conn = connections.get(bundleId);
if (conn) conn.networkLog.length = 0;
},
queryViewHierarchy(bundleId, method, params = {}) {
const conn = connections.get(bundleId);
if (!conn) {
return Promise.reject(
new Error(
"Native devtools not connected for bundleId: " + bundleId
)
);
}
const id = nextRpcId++;
return new Promise((resolve, reject) => {
pendingRpc.set(id, { resolve, reject });
conn.socket.write(
JSON.stringify({
type: "ViewInspector",
payload: { id, method, params },
}) + "\n"
);
setTimeout(() => {
if (pendingRpc.has(id)) {
pendingRpc.delete(id);
reject(new Error(`ViewInspector RPC timed out: ${method}`));
}
}, 5000);
});
},
};
return {
api,
dispose: async () => {
for (const { socket } of connections.values()) {
socket.destroy();
}
connections.clear();
activatedBundleIds.clear();
server.close();
try {
fs.unlinkSync(socketPath);
} catch {}
for (const { reject } of pendingRpc.values()) {
reject(new Error("NativeDevtools service disposed"));
}
pendingRpc.clear();
},
events,
};
},
};
+23 -12
View File
@@ -1,6 +1,7 @@
import { attachRegistryLogger } from "@argent/registry";
import { createHttpApp } from "./http";
import { createRegistry } from "./utils/setup-registry";
import { startSimulatorWatcher } from "./utils/simulator-watcher";
import { DEFAULT_IDLE_TIMEOUT_MINUTES } from "./utils/idle-timer";
import { startUpdateChecker } from "./utils/update-checker";
@@ -19,13 +20,18 @@ const registry = createRegistry();
attachRegistryLogger(registry);
const updateChecker = startUpdateChecker();
// `shutdown` captures `httpHandle` and `server` by closure; safe because it is
// only invoked asynchronously after both are initialized.
const { stop: stopWatcher, ready: watcherReady } = startSimulatorWatcher(registry);
let server: ReturnType<typeof httpHandle.app.listen> | null = null;
// `shutdown` closes over `server` by reference — reads the current value when
// called, so it works correctly whether server has started yet or not.
const shutdown = async () => {
updateChecker.dispose();
stopWatcher();
httpHandle.dispose();
await registry.dispose();
server.close();
server?.close();
process.exit(0);
};
@@ -35,15 +41,20 @@ const httpHandle = createHttpApp(registry, {
onShutdown: shutdown,
});
const server = httpHandle.app.listen(PORT, "127.0.0.1", () => {
const addr = server.address();
const boundPort = typeof addr === "object" && addr ? addr.port : PORT;
process.stdout.write(`Tools server listening on http://127.0.0.1:${boundPort}\n`);
process.stderr.write(` GET http://127.0.0.1:${boundPort}/tools\n`);
process.stderr.write(` POST http://127.0.0.1:${boundPort}/tools/:name\n`);
if (idleTimeoutMs > 0) {
process.stderr.write(` Idle timeout: ${idleMinutes}min\n`);
}
// Block advertising readiness until the first watcher poll completes — this
// guarantees DYLD_INSERT_LIBRARIES is set in launchd for all currently-booted
// simulators before any agent tool call (e.g. launch-app) can arrive.
watcherReady.then(() => {
server = httpHandle.app.listen(PORT, "127.0.0.1", () => {
const addr = server!.address();
const boundPort = typeof addr === "object" && addr ? addr.port : PORT;
process.stdout.write(`Tools server listening on http://127.0.0.1:${boundPort}\n`);
process.stderr.write(` GET http://127.0.0.1:${boundPort}/tools\n`);
process.stderr.write(` POST http://127.0.0.1:${boundPort}/tools/:name\n`);
if (idleTimeoutMs > 0) {
process.stderr.write(` Idle timeout: ${idleMinutes}min\n`);
}
});
});
// ── Lifecycle ───────────────────────────────────────────────────────
@@ -0,0 +1,44 @@
import { z } from "zod";
import type { ToolDefinition } from "@argent/registry";
import type { NativeDevtoolsApi } from "../../blueprints/native-devtools";
import { NATIVE_DEVTOOLS_NAMESPACE } from "../../blueprints/native-devtools";
const zodSchema = z.object({
udid: z.string().describe("Simulator UDID"),
bundleId: z
.string()
.describe("Bundle ID of the app to check (e.g. com.example.MyApp)"),
});
type Params = z.infer<typeof zodSchema>;
type Result = {
envSetup: boolean;
connected: boolean;
requiresRestart: boolean;
};
export const nativeDevtoolsStatusTool: ToolDefinition<Params, Result> = {
id: "native-devtools-status",
description: `Check whether native devtools dylibs are injected into a specific running app on the simulator.
Returns:
- envSetup: DYLD_INSERT_LIBRARIES is configured in the simulator's launchd environment
- connected: the dylib is active in the current running process for this bundleId
- requiresRestart: the app must be restarted before native devtools features are available
Call this before using native-view-hierarchy or native-network-logs.
If requiresRestart is true: call restart-app, then proceed with the native feature.`,
zodSchema,
services: (params) => ({
nativeDevtools: `${NATIVE_DEVTOOLS_NAMESPACE}:${params.udid}`,
}),
async execute(services, params) {
const api = services.nativeDevtools as NativeDevtoolsApi;
const requiresRestart = await api.requiresAppRestart(params.bundleId);
return {
envSetup: api.isEnvSetup(),
connected: api.isConnected(params.bundleId),
requiresRestart,
};
},
};
@@ -0,0 +1,100 @@
import { z } from "zod";
import type { ToolDefinition } from "@argent/registry";
import type { NativeDevtoolsApi } from "../../blueprints/native-devtools";
import { NATIVE_DEVTOOLS_NAMESPACE } from "../../blueprints/native-devtools";
const zodSchema = z.object({
udid: z.string().describe("Simulator UDID"),
bundleId: z.string().describe("Bundle ID of the app"),
className: z
.string()
.optional()
.describe("UIView class name to match (exact, e.g. UIButton)"),
identifier: z
.string()
.optional()
.describe("Accessibility identifier to match (exact)"),
label: z
.string()
.optional()
.describe("Accessibility label to match (exact)"),
tag: z.number().int().optional().describe("UIView tag integer to match"),
nativeID: z
.string()
.optional()
.describe("React Native nativeID prop to match (exact)"),
includeAncestors: z
.boolean()
.optional()
.describe("Include ancestor chain for each matched view (default true)"),
includeChildren: z
.boolean()
.optional()
.describe("Include child views for each matched view (default true)"),
fields: z
.array(z.string())
.optional()
.describe(
"View fields to include. Defaults: className, frame, hidden, alpha, " +
"identifier, label, nativeID, userInteractionEnabled, depth. " +
"Additional: pointer, tag, windowFrame, bounds, center, opaque, " +
"clipsToBounds, transform, contentMode, backgroundColor, tintColor, layerName"
),
});
type Params = z.infer<typeof zodSchema>;
type Result =
| { status: "restart_required"; message: string }
| { status: "ok"; matches: unknown[] };
export const nativeFindViewsTool: ToolDefinition<Params, Result> = {
id: "native-find-views",
description: `Search for specific UIViews in the running app by class name, accessibility identifier, label, tag, or React Native nativeID.
Returns matching views with their frames, properties, optional ancestors, and optional children. Much more targeted than native-full-hierarchy — use this when you know what you're looking for.
At least one of className, identifier, label, tag, or nativeID must be provided.
If status is restart_required: call restart-app then retry.`,
zodSchema,
services: (params) => ({
nativeDevtools: `${NATIVE_DEVTOOLS_NAMESPACE}:${params.udid}`,
}),
async execute(services, params) {
const api = services.nativeDevtools as NativeDevtoolsApi;
if (await api.requiresAppRestart(params.bundleId)) {
return {
status: "restart_required",
message:
"Native devtools are not injected into the running app. " +
"Call restart-app then retry.",
};
}
const rpcParams: Record<string, unknown> = {};
if (params.className !== undefined) rpcParams.className = params.className;
if (params.identifier !== undefined)
rpcParams.identifier = params.identifier;
if (params.label !== undefined) rpcParams.label = params.label;
if (params.tag !== undefined) rpcParams.tag = params.tag;
if (params.nativeID !== undefined) rpcParams.nativeID = params.nativeID;
if (params.includeAncestors !== undefined)
rpcParams.includeAncestors = params.includeAncestors;
if (params.includeChildren !== undefined)
rpcParams.includeChildren = params.includeChildren;
if (params.fields !== undefined) rpcParams.fields = params.fields;
const result = (await api.queryViewHierarchy(
params.bundleId,
"ViewHierarchy.findViews",
rpcParams
)) as { matches?: unknown[]; error?: string };
if (result.error) {
throw new Error(result.error);
}
return { status: "ok", matches: result.matches ?? [] };
},
};
@@ -0,0 +1,98 @@
import { z } from "zod";
import type { ToolDefinition } from "@argent/registry";
import type { NativeDevtoolsApi } from "../../blueprints/native-devtools";
import { NATIVE_DEVTOOLS_NAMESPACE } from "../../blueprints/native-devtools";
const zodSchema = z.object({
udid: z.string().describe("Simulator UDID"),
bundleId: z.string().describe("Bundle ID of the app"),
fields: z
.array(z.string())
.optional()
.describe(
"View fields to include. Use EXACT names: " +
"className, frame, hidden, alpha, identifier, label, nativeID, " +
"userInteractionEnabled, depth, pointer, tag, windowFrame, bounds, " +
"center, opaque, clipsToBounds, transform, contentMode, " +
"backgroundColor, tintColor, layerName. " +
"Defaults to all of the first group when omitted."
),
skipClasses: z
.array(z.string())
.optional()
.describe(
"Exact UIView class names whose entire subtree should be pruned " +
"(e.g. [\"UIImageView\"] to drop image leaf nodes)"
),
skipClassPrefixes: z
.array(z.string())
.optional()
.describe(
"Class name prefixes to prune entire subtrees. " +
"For SwiftUI apps use [\"_TtGC7SwiftUI\"] to drop mangled SwiftUI " +
"generic type subtrees while keeping _UIHostingView and UIKit bridges. " +
"Avoid broad prefixes like \"_UI\" — they prune useful system views."
),
maxDepth: z
.number()
.int()
.positive()
.optional()
.describe(
"Maximum recursion depth (default 8). Increase for deeper inspection, " +
"decrease to reduce output size."
),
});
type Params = z.infer<typeof zodSchema>;
type Result =
| { status: "restart_required"; message: string }
| { status: "ok"; windows: unknown[] };
export const nativeFullHierarchyTool: ToolDefinition<Params, Result> = {
id: "native-full-hierarchy",
description: `Get the complete UIKit view tree for the running app.
WARNING: Output can be extremely large (100KB500KB+) for complex apps, especially
those built with SwiftUI. Prefer native-find-views for targeted queries.
Use skipClasses / skipClassPrefixes to prune SwiftUI internal subtrees and reduce
output size. Use the fields param to request only the properties you need.
Useful for: deep layout debugging, finding views with no accessibility labels,
verifying view structure not exposed through the accessibility tree.
If status is restart_required: call restart-app then retry.`,
zodSchema,
services: (params) => ({
nativeDevtools: `${NATIVE_DEVTOOLS_NAMESPACE}:${params.udid}`,
}),
async execute(services, params) {
const api = services.nativeDevtools as NativeDevtoolsApi;
if (await api.requiresAppRestart(params.bundleId)) {
return {
status: "restart_required",
message:
"Native devtools are not injected into the running app. " +
"Call restart-app then retry.",
};
}
const rpcParams: Record<string, unknown> = {};
if (params.fields !== undefined) rpcParams.fields = params.fields;
if (params.skipClasses !== undefined)
rpcParams.skipClasses = params.skipClasses;
if (params.skipClassPrefixes !== undefined)
rpcParams.skipClassPrefixes = params.skipClassPrefixes;
if (params.maxDepth !== undefined) rpcParams.maxDepth = params.maxDepth;
const result = (await api.queryViewHierarchy(
params.bundleId,
"ViewHierarchy.getFullHierarchy",
rpcParams
)) as { windows?: unknown[] };
return { status: "ok", windows: result.windows ?? [] };
},
};
@@ -0,0 +1,57 @@
import { z } from "zod";
import type { ToolDefinition } from "@argent/registry";
import type { NativeDevtoolsApi, NetworkEvent } from "../../blueprints/native-devtools";
import { NATIVE_DEVTOOLS_NAMESPACE } from "../../blueprints/native-devtools";
const zodSchema = z.object({
udid: z.string().describe("Simulator UDID"),
bundleId: z.string().describe("Bundle ID of the app"),
limit: z
.number()
.optional()
.default(50)
.describe("Maximum number of events to return (most recent first)"),
clear: z
.boolean()
.optional()
.default(false)
.describe("Clear the log after reading"),
});
type Params = z.infer<typeof zodSchema>;
type Result =
| { status: "restart_required"; message: string }
| { status: "ok"; count: number; events: NetworkEvent[] };
export const nativeNetworkLogsTool: ToolDefinition<Params, Result> = {
id: "native-network-logs",
description: `View network requests captured at the native NSURLProtocol level.
Unlike the JS-level network inspector (view-network-logs), this captures ALL network
traffic from the app including native modules, Swift/Objective-C networking, and
background transfers that bypass JS fetch.
If requiresRestart is returned: call restart-app then retry.`,
zodSchema,
services: (params) => ({
nativeDevtools: `${NATIVE_DEVTOOLS_NAMESPACE}:${params.udid}`,
}),
async execute(services, params) {
const api = services.nativeDevtools as NativeDevtoolsApi;
if (await api.requiresAppRestart(params.bundleId)) {
return {
status: "restart_required",
message:
"Native devtools are not injected into the running app. " +
"Call restart-app then retry.",
};
}
api.activateNetworkInspection(params.bundleId);
const events = api.getNetworkLog(params.bundleId).slice(-params.limit);
if (params.clear) api.clearNetworkLog(params.bundleId);
return { status: "ok", count: events.length, events };
},
};
@@ -1,8 +1,12 @@
import { ServiceState } from "@argent/registry";
import type { Registry, ToolDefinition } from "@argent/registry";
import { SIMULATOR_SERVER_NAMESPACE } from "../../blueprints/simulator-server";
import { NATIVE_DEVTOOLS_NAMESPACE } from "../../blueprints/native-devtools";
const PREFIX = `${SIMULATOR_SERVER_NAMESPACE}:`;
const PREFIXES = [
`${SIMULATOR_SERVER_NAMESPACE}:`,
`${NATIVE_DEVTOOLS_NAMESPACE}:`,
];
export function createStopAllSimulatorServersTool(
registry: Registry,
@@ -10,14 +14,17 @@ export function createStopAllSimulatorServersTool(
return {
id: "stop-all-simulator-servers",
description:
"Stop all running simulator-server processes. " +
"Stop all running simulator-server processes and native devtools services. " +
"Call this when your session ends or the user says they are done, to free resources.",
services: () => ({}),
async execute() {
const snapshot = registry.getSnapshot();
const stopped: string[] = [];
for (const [urn, entry] of snapshot.services) {
if (urn.startsWith(PREFIX) && entry.state !== ServiceState.IDLE) {
if (
PREFIXES.some((p) => urn.startsWith(p)) &&
entry.state !== ServiceState.IDLE
) {
await registry.disposeService(urn);
stopped.push(urn);
}
@@ -1,5 +1,10 @@
import { Registry } from "@argent/registry";
import { simulatorServerBlueprint } from "../blueprints/simulator-server";
import { nativeDevtoolsBlueprint } from "../blueprints/native-devtools";
import { nativeDevtoolsStatusTool } from "../tools/native-devtools/native-devtools-status";
import { nativeNetworkLogsTool } from "../tools/native-devtools/native-network-logs";
import { nativeFindViewsTool } from "../tools/native-devtools/native-find-views";
import { nativeFullHierarchyTool } from "../tools/native-devtools/native-full-hierarchy";
import { jsRuntimeDebuggerBlueprint } from "../blueprints/js-runtime-debugger";
import { networkInspectorBlueprint } from "../blueprints/network-inspector";
import { reactProfilerSessionBlueprint } from "../blueprints/react-profiler-session";
@@ -65,6 +70,7 @@ export function createRegistry(): Registry {
registry.registerBlueprint(networkInspectorBlueprint);
registry.registerBlueprint(reactProfilerSessionBlueprint);
registry.registerBlueprint(iosInstrumentsSessionBlueprint);
registry.registerBlueprint(nativeDevtoolsBlueprint);
registry.registerTool(listSimulatorsTool);
registry.registerTool(bootSimulatorTool);
@@ -109,6 +115,10 @@ export function createRegistry(): Registry {
registry.registerTool(profilerCombinedReportTool);
registry.registerTool(profilerLoadTool);
registry.registerTool(gatherWorkspaceDataTool);
registry.registerTool(nativeDevtoolsStatusTool);
registry.registerTool(nativeNetworkLogsTool);
registry.registerTool(nativeFindViewsTool);
registry.registerTool(nativeFullHierarchyTool);
// Cleanup tools (close over registry for direct service disposal)
registry.registerTool(createStopSimulatorServerTool(registry));
@@ -0,0 +1,89 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import type { Registry } from "@argent/registry";
import { NATIVE_DEVTOOLS_NAMESPACE } from "../blueprints/native-devtools";
const execFileAsync = promisify(execFile);
const POLL_INTERVAL_MS = 10_000;
async function getBootedUdids(): Promise<Set<string>> {
const { stdout } = await execFileAsync("xcrun", [
"simctl",
"list",
"devices",
"--json",
]);
const data = JSON.parse(stdout) as {
devices: Record<string, Array<{ udid: string; state: string }>>;
};
const udids = new Set<string>();
for (const devices of Object.values(data.devices)) {
for (const device of devices) {
if (device.state === "Booted") udids.add(device.udid);
}
}
return udids;
}
async function initSimulator(
registry: Registry,
watchedUdids: Set<string>,
udid: string
): Promise<void> {
watchedUdids.add(udid);
try {
await registry.resolveService(`${NATIVE_DEVTOOLS_NAMESPACE}:${udid}`);
} catch {
// Service failed to start (e.g. simulator shut down mid-init); retry next tick
watchedUdids.delete(udid);
}
}
export function startSimulatorWatcher(
registry: Registry
): { stop: () => void; ready: Promise<void> } {
const watchedUdids = new Set<string>();
async function poll(awaitInit: boolean): Promise<void> {
let booted: Set<string>;
try {
booted = await getBootedUdids();
} catch {
// xcrun unavailable or transient error — skip this tick
return;
}
// New simulators: start NativeDevtools service (sets launchd env + opens socket)
const newUdids = [...booted].filter((udid) => !watchedUdids.has(udid));
if (awaitInit) {
// First poll: await all ensureEnv completions so the server is only marked
// ready after injection is guaranteed for all currently-booted simulators.
await Promise.all(
newUdids.map((udid) => initSimulator(registry, watchedUdids, udid))
);
} else {
// Subsequent polls: fire-and-forget to avoid blocking the interval tick.
newUdids.forEach((udid) => initSimulator(registry, watchedUdids, udid));
}
// Simulators that shut down: dispose service and clean up
for (const udid of watchedUdids) {
if (!booted.has(udid)) {
watchedUdids.delete(udid);
registry
.disposeService(`${NATIVE_DEVTOOLS_NAMESPACE}:${udid}`)
.catch(() => {});
}
}
}
// First poll is awaited — server startup blocks until ensureEnv completes for
// all booted simulators, eliminating the race with launch-app.
const ready = poll(true);
// Subsequent polls are fire-and-forget.
const interval = setInterval(() => poll(false), POLL_INTERVAL_MS);
return { stop: () => clearInterval(interval), ready };
}
+31 -12
View File
@@ -63,19 +63,38 @@ async function waitForHttp(url, timeoutMs = 20_000) {
// ── Step 1: Build native devtools dylibs ─────────────────────────────────────
console.log("Initialising argent-private submodule...");
execSync("git submodule update --init packages/argent-private", {
cwd: ROOT,
stdio: "inherit",
});
console.log("✓ Submodule ready\n");
const DYLIBS_DIR = path.join(NATIVE_DEVTOOLS_PKG, "dylibs");
const DYLIBS_EXIST = fs.existsSync(path.join(DYLIBS_DIR, "libNativeDevtoolsIos.dylib"));
console.log("Building native devtools dylibs...");
execSync("bash scripts/build.sh dev", {
cwd: NATIVE_DEVTOOLS_PKG,
stdio: "inherit",
});
console.log("✓ Native devtools dylibs built\n");
// Try to init the submodule and rebuild. Failure is non-fatal if pre-built
// dylibs are already present — developers without argent-private access can
// still work on Argent using the committed binaries.
let submoduleReady = false;
try {
execSync("git submodule update --init packages/argent-private", {
cwd: ROOT,
stdio: "pipe",
});
submoduleReady = true;
} catch {
if (DYLIBS_EXIST) {
console.warn("⚠ argent-private submodule unavailable — using pre-built dylibs\n");
} else {
console.error("✗ argent-private submodule unavailable and no pre-built dylibs found.");
console.error(" Grant SSH access to github.com/software-mansion-labs/argent-private");
console.error(" or obtain pre-built dylibs and place them in packages/native-devtools-ios/dylibs/");
process.exit(1);
}
}
if (submoduleReady) {
console.log("Building native devtools dylibs...");
execSync("bash scripts/build.sh dev", {
cwd: NATIVE_DEVTOOLS_PKG,
stdio: "inherit",
});
console.log("✓ Native devtools dylibs built\n");
}
// ── Step 2: Build MCP TypeScript ─────────────────────────────────────────────