Skip to content

Commit c5470b6

Browse files
committed
Optimize warm CLI control path
1 parent 59b9226 commit c5470b6

9 files changed

Lines changed: 595 additions & 86 deletions

File tree

README.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,16 @@ The browser bootstrap comes from `GET /api/health`, which returns the WebTranspo
5858
certificate hash, and packet version needed by the client.
5959
The served browser UI receives the generated API access token automatically; direct HTTP callers can use the startup token with `X-SimDeck-Token` or `Authorization: Bearer`.
6060

61+
For fastest agent control, keep `simdeck serve` or `simdeck service on` running and route hot CLI controls through the warm local service:
62+
63+
```sh
64+
export SIMDECK_SERVER_URL=http://127.0.0.1:4310
65+
simdeck tap <udid> 0.5 0.5 --normalized
66+
simdeck describe-ui <udid> --format agent --max-depth 2
67+
```
68+
69+
You can also pass `--server-url http://127.0.0.1:4310` on individual commands. Supported fast-path controls include launch/open-url, normalized touch/tap/swipe/gesture input, key/key-sequence/key-combo, hardware buttons, dismiss-keyboard, home/app-switcher, rotate, and appearance toggles.
70+
6171
## Service
6272

6373
Enable the per-user background service with `launchd`:
@@ -139,7 +149,8 @@ UIKit in-app inspectors, then falls back to the built-in private CoreSimulator
139149
accessibility bridge. Use `--format agent` or `--format compact-json` for
140150
lower-token hierarchy dumps. Coordinate commands accept screen coordinates from
141151
the accessibility tree by default; pass `--normalized` to send `0.0..1.0`
142-
coordinates directly.
152+
coordinates directly. With `--server-url` or `SIMDECK_SERVER_URL`, normalized
153+
input commands use the warm service path to avoid repeated native setup.
143154

144155
## NativeScript Inspector
145156

docs/api/rest.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,26 @@ Content-Type: application/json
175175

176176
Allowed `phase` values: `began`, `moved`, `ended`, `cancelled`.
177177

178+
### `POST /api/simulators/{udid}/touch-sequence`
179+
180+
Replays multiple normalized touch events through one native input session:
181+
182+
```http
183+
POST /api/simulators/{udid}/touch-sequence
184+
Content-Type: application/json
185+
186+
{
187+
"events": [
188+
{ "x": 0.5, "y": 0.7, "phase": "began", "delayMsAfter": 25 },
189+
{ "x": 0.5, "y": 0.4, "phase": "moved", "delayMsAfter": 25 },
190+
{ "x": 0.5, "y": 0.2, "phase": "ended" }
191+
]
192+
}
193+
```
194+
195+
This is the preferred API for agent gestures because it avoids one HTTP request
196+
per touch phase.
197+
178198
### `POST /api/simulators/{udid}/key`
179199

180200
Replays a single keyboard event by HID key code:
@@ -188,6 +208,33 @@ Content-Type: application/json
188208

189209
`keyCode` is the HID usage value. `modifiers` is a bitmask defined by the HID input subsystem (defaults to `0`).
190210

211+
### `POST /api/simulators/{udid}/key-sequence`
212+
213+
Replays multiple HID key codes through one native input session:
214+
215+
```http
216+
POST /api/simulators/{udid}/key-sequence
217+
Content-Type: application/json
218+
219+
{ "keyCodes": [11, 8, 15, 15, 18], "delayMs": 5 }
220+
```
221+
222+
`delayMs` defaults to `0`.
223+
224+
### `POST /api/simulators/{udid}/button`
225+
226+
Presses a hardware button:
227+
228+
```http
229+
POST /api/simulators/{udid}/button
230+
Content-Type: application/json
231+
232+
{ "button": "lock", "durationMs": 50 }
233+
```
234+
235+
Supported button names match the CLI: `home`, `lock`, `side-button`, `siri`,
236+
and `apple-pay`. `durationMs` defaults to `0`.
237+
191238
### `POST /api/simulators/{udid}/home`
192239

193240
Presses the home button:

docs/cli/commands.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,24 @@ Use `--format agent` for compact hierarchy text intended for LLM planning, and
118118
`--point` returns the native element at a screen point and uses the native point
119119
query directly.
120120

121+
## Warm service fast path
122+
123+
Most agent loops should keep `simdeck serve` or `simdeck service on` running and
124+
set:
125+
126+
```sh
127+
export SIMDECK_SERVER_URL=http://127.0.0.1:4310
128+
```
129+
130+
Supported hot controls then use the local HTTP service and avoid repeated native
131+
setup in short-lived CLI processes. This fast path covers `launch`, `open-url`,
132+
normalized `touch`, normalized coordinate `tap`, normalized `swipe`, normalized
133+
`gesture`, `key`, `key-sequence`, `key-combo`, `dismiss-keyboard`, `home`,
134+
`app-switcher`, `button`, `rotate-left`, `rotate-right`, and
135+
`toggle-appearance`. Commands that need local files, selector-to-point
136+
resolution, screenshots, pasteboard, or batch execution stay on the direct
137+
native path.
138+
121139
## `boot`
122140

123141
Boot a simulator by UDID:

docs/cli/flags.md

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,17 @@ Encoder used by the native bridge. See [Video Pipeline](/guide/video) for when t
7575

7676
HTTP API and WebTransport access token. The served browser UI receives it automatically through a strict same-site cookie, so normal local use does not require copying the token. Direct API callers should send either `X-SimDeck-Token: <token>` or `Authorization: Bearer <token>`.
7777

78+
## Global CLI flags
79+
80+
### `--server-url <url>`
81+
82+
| Default | unset |
83+
| ------- | ------------------------------ |
84+
| Env | `SIMDECK_SERVER_URL` |
85+
| Type | `http://` URL for local server |
86+
87+
When set, supported hot controls delegate to the warm local SimDeck service instead of starting a fresh native control path in the CLI process. This is fastest for agent-driven loops. Supported delegated controls include `launch`, `open-url`, normalized `touch`, normalized coordinate `tap`, normalized `swipe`, normalized `gesture`, `key`, `key-sequence`, `key-combo`, `button`, `dismiss-keyboard`, `home`, `app-switcher`, `rotate-left`, `rotate-right`, and `toggle-appearance`.
88+
7889
## Positional arguments
7990

8091
Subcommands that take positionals expect them in the order shown:
@@ -88,15 +99,15 @@ Subcommands that take positionals expect them in the order shown:
8899

89100
## `describe-ui` flags
90101

91-
| Flag | Default | Description |
92-
| -------------------- | ------------------------------- | -------------------------------------------------------------------------------- |
93-
| `--format` | `json` | Output format: `json`, `compact-json`, or `agent`. |
94-
| `--source` | `auto` | Hierarchy source: `auto`, `nativescript`, `uikit`, or `native-ax`. |
95-
| `--max-depth` | unlimited native / `80` service | Trim descendants after the requested depth. |
96-
| `--include-hidden` | `false` | Include hidden in-app inspector views when supported. |
97-
| `--direct` | `false` | Skip the local service and use the private native accessibility bridge directly. |
98-
| `--point <x>,<y>` | unset | Return the native element at a screen point. |
99-
| `--server-url <url>` | `http://127.0.0.1:4310` | Local service URL used for source-aware hierarchy requests. |
102+
| Flag | Default | Description |
103+
| -------------------- | -------------------------------------- | -------------------------------------------------------------------------------- |
104+
| `--format` | `json` | Output format: `json`, `compact-json`, or `agent`. |
105+
| `--source` | `auto` | Hierarchy source: `auto`, `nativescript`, `uikit`, or `native-ax`. |
106+
| `--max-depth` | unlimited native / `80` service | Trim descendants after the requested depth. |
107+
| `--include-hidden` | `false` | Include hidden in-app inspector views when supported. |
108+
| `--direct` | `false` | Skip the local service and use the private native accessibility bridge directly. |
109+
| `--point <x>,<y>` | unset | Return the native element at a screen point. |
110+
| `--server-url <url>` | global flag or `http://127.0.0.1:4310` | Local service URL used for source-aware hierarchy requests. |
100111

101112
## Exit codes
102113

docs/cli/index.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@ The `simdeck` binary is the only entrypoint SimDeck ships. It hosts the HTTP ser
55
## Synopsis
66

77
```sh
8-
simdeck <COMMAND> [OPTIONS]
8+
simdeck [--server-url <url>] <COMMAND> [OPTIONS]
99
```
1010

11+
Set `SIMDECK_SERVER_URL=http://127.0.0.1:4310` or pass `--server-url` to route supported hot controls through an already-running local service. That avoids repeated native setup for agent loops while preserving the same JSON command output.
12+
1113
## Top-level commands
1214

1315
| Command | Purpose |

scripts/integration/cli.mjs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -943,7 +943,7 @@ async function retrySimdeckText(args, label, options = {}) {
943943

944944
async function cliStep(label, args, commandOptions = {}, verifyOptions = {}) {
945945
return measuredStep(label, async () => {
946-
const result = await retrySimdeckJson(args, label, {
946+
const result = await retrySimdeckJson(cliArgs(args), label, {
947947
maxElapsedMs: cliCommandBudgetMs,
948948
...commandOptions,
949949
});
@@ -952,6 +952,10 @@ async function cliStep(label, args, commandOptions = {}, verifyOptions = {}) {
952952
});
953953
}
954954

955+
function cliArgs(args) {
956+
return serverProcess ? ["--server-url", serverUrl, ...args] : args;
957+
}
958+
955959
async function httpStep(
956960
label,
957961
method,

server/src/api/routes.rs

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,13 +57,42 @@ struct TouchPayload {
5757
phase: String,
5858
}
5959

60+
#[derive(Deserialize)]
61+
struct TouchSequencePayload {
62+
events: Vec<TouchSequenceEvent>,
63+
}
64+
65+
#[derive(Deserialize)]
66+
struct TouchSequenceEvent {
67+
x: f64,
68+
y: f64,
69+
phase: String,
70+
#[serde(rename = "delayMsAfter")]
71+
delay_ms_after: Option<u64>,
72+
}
73+
6074
#[derive(Deserialize)]
6175
struct KeyPayload {
6276
#[serde(rename = "keyCode")]
6377
key_code: u16,
6478
modifiers: Option<u32>,
6579
}
6680

81+
#[derive(Deserialize)]
82+
struct KeySequencePayload {
83+
#[serde(rename = "keyCodes")]
84+
key_codes: Vec<u16>,
85+
#[serde(rename = "delayMs")]
86+
delay_ms: Option<u64>,
87+
}
88+
89+
#[derive(Deserialize)]
90+
struct ButtonPayload {
91+
button: String,
92+
#[serde(rename = "durationMs")]
93+
duration_ms: Option<u32>,
94+
}
95+
6796
#[derive(Deserialize)]
6897
struct AccessibilityPointQuery {
6998
x: f64,
@@ -142,11 +171,20 @@ pub fn router(state: AppState) -> Router {
142171
.route("/api/simulators/{udid}/open-url", post(open_url))
143172
.route("/api/simulators/{udid}/launch", post(launch_bundle))
144173
.route("/api/simulators/{udid}/touch", post(send_touch))
174+
.route(
175+
"/api/simulators/{udid}/touch-sequence",
176+
post(send_touch_sequence),
177+
)
145178
.route("/api/simulators/{udid}/key", post(send_key))
179+
.route(
180+
"/api/simulators/{udid}/key-sequence",
181+
post(send_key_sequence),
182+
)
146183
.route(
147184
"/api/simulators/{udid}/dismiss-keyboard",
148185
post(dismiss_keyboard),
149186
)
187+
.route("/api/simulators/{udid}/button", post(press_button))
150188
.route("/api/simulators/{udid}/home", post(press_home))
151189
.route(
152190
"/api/simulators/{udid}/app-switcher",
@@ -420,6 +458,46 @@ async fn send_touch(
420458
Ok(json(json_value!({ "ok": true })))
421459
}
422460

461+
async fn send_touch_sequence(
462+
State(state): State<AppState>,
463+
Path(udid): Path<String>,
464+
Json(payload): Json<TouchSequencePayload>,
465+
) -> Result<Json<Value>, AppError> {
466+
if payload.events.is_empty() {
467+
return Err(AppError::bad_request(
468+
"Request body must include at least one touch event.",
469+
));
470+
}
471+
if payload.events.len() > 64 {
472+
return Err(AppError::bad_request(
473+
"Touch sequence cannot contain more than 64 events.",
474+
));
475+
}
476+
for event in &payload.events {
477+
if !event.x.is_finite() || !event.y.is_finite() {
478+
return Err(AppError::bad_request(
479+
"`x` and `y` must be finite normalized numbers.",
480+
));
481+
}
482+
}
483+
run_bridge_action(state, move |bridge| {
484+
let input = bridge.create_input_session(&udid)?;
485+
for event in payload.events {
486+
input.send_touch(
487+
event.x.clamp(0.0, 1.0),
488+
event.y.clamp(0.0, 1.0),
489+
&event.phase,
490+
)?;
491+
if let Some(delay_ms) = event.delay_ms_after.filter(|delay_ms| *delay_ms > 0) {
492+
std::thread::sleep(Duration::from_millis(delay_ms));
493+
}
494+
}
495+
Ok(())
496+
})
497+
.await?;
498+
Ok(json(json_value!({ "ok": true })))
499+
}
500+
423501
async fn send_key(
424502
State(state): State<AppState>,
425503
Path(udid): Path<String>,
@@ -432,6 +510,37 @@ async fn send_key(
432510
Ok(json(json_value!({ "ok": true })))
433511
}
434512

513+
async fn send_key_sequence(
514+
State(state): State<AppState>,
515+
Path(udid): Path<String>,
516+
Json(payload): Json<KeySequencePayload>,
517+
) -> Result<Json<Value>, AppError> {
518+
if payload.key_codes.is_empty() {
519+
return Err(AppError::bad_request(
520+
"Request body must include at least one key code.",
521+
));
522+
}
523+
if payload.key_codes.len() > 512 {
524+
return Err(AppError::bad_request(
525+
"Key sequence cannot contain more than 512 key codes.",
526+
));
527+
}
528+
run_bridge_action(state, move |bridge| {
529+
let input = bridge.create_input_session(&udid)?;
530+
let delay_ms = payload.delay_ms.unwrap_or(0);
531+
let key_count = payload.key_codes.len();
532+
for (index, key_code) in payload.key_codes.into_iter().enumerate() {
533+
input.send_key(key_code, 0)?;
534+
if delay_ms > 0 && index + 1 < key_count {
535+
std::thread::sleep(Duration::from_millis(delay_ms));
536+
}
537+
}
538+
Ok(())
539+
})
540+
.await?;
541+
Ok(json(json_value!({ "ok": true })))
542+
}
543+
435544
async fn dismiss_keyboard(
436545
State(state): State<AppState>,
437546
Path(udid): Path<String>,
@@ -440,6 +549,21 @@ async fn dismiss_keyboard(
440549
Ok(json(json_value!({ "ok": true })))
441550
}
442551

552+
async fn press_button(
553+
State(state): State<AppState>,
554+
Path(udid): Path<String>,
555+
Json(payload): Json<ButtonPayload>,
556+
) -> Result<Json<Value>, AppError> {
557+
if payload.button.trim().is_empty() {
558+
return Err(AppError::bad_request("Request body must include `button`."));
559+
}
560+
run_bridge_action(state, move |bridge| {
561+
bridge.press_button(&udid, &payload.button, payload.duration_ms.unwrap_or(0))
562+
})
563+
.await?;
564+
Ok(json(json_value!({ "ok": true })))
565+
}
566+
443567
async fn press_home(
444568
State(state): State<AppState>,
445569
Path(udid): Path<String>,

0 commit comments

Comments
 (0)