Skip to content

Commit 1ccfa12

Browse files
authored
feat(ios): support gateway WDA base URLs (#3187)
* feat(ios): support gateway WDA base URLs * fix(ios): reject conflicting WDA connection options * feat(ios): support independent gateway MJPEG URLs * fix(studio): preserve iOS gateway target identity and replay config * fix(studio): retain legacy iOS recording history matches * test(playground): keep MJPEG mock cleanup void * test(ios): exercise path-prefixed WDA on simulator CI * test(ios): exercise explicit legacy WDA host and port
1 parent ea75076 commit 1ccfa12

34 files changed

Lines changed: 1301 additions & 107 deletions

‎apps/site/docs/en/automate-with-scripts-in-yaml.mdx‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,17 @@ ios:
367367
# WebDriverAgent host address, optional, defaults to localhost.
368368
wdaHost: <host>
369369
370+
# For gateways with a path prefix, use wdaBaseUrl instead of wdaHost/wdaPort.
371+
# Setting wdaBaseUrl together with either wdaHost or wdaPort causes an error.
372+
# wdaBaseUrl: <url>
373+
374+
# Optional independent MJPEG stream URL, including a gateway path if needed.
375+
# Cannot be combined with wdaMjpegPort.
376+
# wdaMjpegUrl: <url>
377+
378+
# Local MJPEG stream port, optional. Use this or wdaMjpegUrl.
379+
# wdaMjpegPort: <port>
380+
370381
# Whether to auto dismiss keyboard, optional, defaults to false.
371382
autoDismissKeyboard: <boolean>
372383
@@ -383,6 +394,16 @@ ios:
383394
# See the IOSDevice constructor documentation for the complete list
384395
```
385396

397+
For a gateway with a path prefix, set `wdaBaseUrl` and leave `wdaHost` and `wdaPort` unset. The MJPEG stream can use a separate URL:
398+
399+
```yaml
400+
ios:
401+
wdaBaseUrl: ${WDA_BASE_URL}
402+
wdaMjpegUrl: ${WDA_MJPEG_URL}
403+
```
404+
405+
Set both environment variables before running the script. Studio Recorder exports use these references so gateway paths and access tokens stay out of the YAML file.
406+
386407
:::info View Complete iOS Configuration Options
387408
388409
YAML scripts now support all configuration options from the `IOSDevice` constructor. For the complete list of options, see [`IOSDevice`](./reference/#iosdevice) in the iOS API reference.

‎apps/site/docs/en/platforms/ios.mdx‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,27 @@ For remote devices, you also need to set up port forwarding accordingly:
233233
iproxy 8100 8100 YOUR_DEVICE_ID
234234
```
235235

236+
If a gateway exposes WDA under a path prefix, set the full API base URL instead:
237+
238+
```typescript
239+
const agent = await agentFromWebDriverAgent({
240+
wdaBaseUrl: 'https://gateway.example/device/wda',
241+
});
242+
```
243+
244+
Midscene appends `/status`, `/session`, and session commands to this base URL. Set either `wdaBaseUrl` or `wdaHost`/`wdaPort`; combining them throws an error.
245+
246+
`wdaBaseUrl` only configures the WDA API. By default, the native MJPEG stream still connects to `http://localhost:9100` when using a gateway. If the gateway also exposes a stream, set its complete URL separately:
247+
248+
```typescript
249+
const agent = await agentFromWebDriverAgent({
250+
wdaBaseUrl: 'https://gateway.example/device/wda',
251+
wdaMjpegUrl: 'https://gateway.example/device/mjpeg',
252+
});
253+
```
254+
255+
`wdaMjpegUrl` accepts an HTTP(S) stream URL with its own host, port, path, and query. Credentials and fragments in the URL are rejected. It cannot be combined with `wdaMjpegPort`. For a local port forward, leave `wdaMjpegUrl` unset and use `wdaMjpegPort` (default `9100`). Playground falls back to screenshot polling if the native stream is unavailable.
256+
236257
### How to get smoother live screen preview in Playground?
237258

238259
Playground's screen preview supports two modes:

‎apps/site/docs/en/reference/index.mdx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2499,9 +2499,11 @@ const device = new IOSDevice({
24992499

25002500
- `wdaPort?: number` — WebDriverAgent port. Default: `8100`.
25012501
- `wdaHost?: string` — WebDriverAgent host. Default: `'localhost'`.
2502+
- `wdaBaseUrl?: string` — Full HTTP(S) WDA API base URL, including a gateway path prefix. Cannot be combined with `wdaHost` or `wdaPort`.
25022503
- `iOSDeviceClassOverride?: string` — Optional npm module path that replaces the default `IOSDevice` when using `agentFromWebDriverAgent()` or iOS Playground. The module must export an `IOSDevice` class or a default class.
25032504
- `sessionId?: string` — Existing WebDriverAgent session ID to reuse. When provided, Midscene skips creating a new WDA session. During cleanup, Midscene detaches from the externally supplied WebDriver session instead of deleting it.
25042505
- `wdaMjpegPort?: number` — WDA MJPEG server port for real-time screen streaming. Default: `9100`.
2506+
- `wdaMjpegUrl?: string` — Full HTTP(S) MJPEG stream URL, including any gateway path or query. Cannot be combined with `wdaMjpegPort`.
25052507
- `wdaMjpegFrameSource?: { enabled?: boolean }` — Use WDA's MJPEG stream as the continuous frame source for `agent.startObserving()`. Disabled by default; when disabled, observers fall back to sequential `screenshotBase64()` capture.
25062508
- `autoDismissKeyboard?: boolean` — Whether to hide the on-screen keyboard after text input. Default: `true`.
25072509
- `keyboardTypeDelay?: number` — Finite non-negative delay in milliseconds between keystrokes. A positive value makes legacy input enter one Unicode code point at a time through WDA's `/wda/keys` endpoint. Use this option when an input field drops characters during fast input.
@@ -2512,6 +2514,8 @@ const device = new IOSDevice({
25122514

25132515
- Ensure Developer Mode is enabled and WDA can reach the device; use `iproxy` when forwarding ports from a real device.
25142516
- Use `wdaHost`/`wdaPort` to target remote devices or custom WDA deployments.
2517+
- Use `wdaBaseUrl` when a gateway routes WDA through a path prefix; all WDA API requests and readiness checks use that prefix.
2518+
- Set `wdaMjpegUrl` separately when the native MJPEG stream is available through a gateway. The API base URL does not determine the stream URL.
25152519
- For multi-device concurrency, use distinct `wdaPort` and `wdaMjpegPort` values for each device so WDA commands and MJPEG streams do not conflict.
25162520
- For shared interaction methods, see [Shared Agent APIs](#interaction-methods).
25172521

‎apps/site/docs/zh/automate-with-scripts-in-yaml.mdx‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,16 @@ ios:
370370
# WebDriverAgent 主机地址,可选,默认 localhost
371371
wdaHost: <host>
372372
373+
# 网关带路径前缀时,用 wdaBaseUrl 替代 wdaHost/wdaPort。
374+
# 同时设置 wdaBaseUrl 与 wdaHost 或 wdaPort 会报错。
375+
# wdaBaseUrl: <url>
376+
377+
# 可单独设置 MJPEG 流地址,包括网关路径;不能与 wdaMjpegPort 同时设置。
378+
# wdaMjpegUrl: <url>
379+
380+
# 本地 MJPEG 流端口,可选。与 wdaMjpegUrl 二选一。
381+
# wdaMjpegPort: <port>
382+
373383
# 是否自动关闭键盘,可选,默认 false
374384
autoDismissKeyboard: <boolean>
375385
@@ -386,6 +396,16 @@ ios:
386396
# 完整配置项请参考 IOSDevice 的构造函数文档
387397
```
388398

399+
连接带路径前缀的网关时,只设置 `wdaBaseUrl`。MJPEG 流可以使用单独的地址:
400+
401+
```yaml
402+
ios:
403+
wdaBaseUrl: ${WDA_BASE_URL}
404+
wdaMjpegUrl: ${WDA_MJPEG_URL}
405+
```
406+
407+
运行脚本前,请设置这两个环境变量。Studio Recorder 导出的 YAML 也引用环境变量。这样,网关路径和访问令牌不会写入文件。
408+
389409
:::info 查看完整的 iOS 配置项
390410
391411
YAML 脚本现在支持 `IOSDevice` 构造函数的所有配置选项。完整的配置项列表请参考 [iOS API 参考中的 IOSDevice](./reference/#iosdevice)。

‎apps/site/docs/zh/platforms/ios.mdx‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,29 @@ const agent = await agentFromWebDriverAgent({
233233
iproxy 8100 8100 YOUR_DEVICE_ID
234234
```
235235

236+
如果网关把 WDA 放在带路径前缀的地址下,可以直接配置完整的 API 基地址:
237+
238+
```typescript
239+
const agent = await agentFromWebDriverAgent({
240+
wdaBaseUrl: 'https://gateway.example/device/wda',
241+
});
242+
```
243+
244+
Midscene 会在该地址后追加 `/status`、`/session` 和会话接口路径。连接网关时,只设置 `wdaBaseUrl`。如果使用主机和端口,则设置 `wdaHost` 和 `wdaPort`。两种方式混用会报错。
245+
246+
`wdaBaseUrl` 只配置 WDA API。MJPEG 流默认连接 `http://localhost:9100`。如果网关也提供画面流,请单独设置完整地址:
247+
248+
```typescript
249+
const agent = await agentFromWebDriverAgent({
250+
wdaBaseUrl: 'https://gateway.example/device/wda',
251+
wdaMjpegUrl: 'https://gateway.example/device/mjpeg',
252+
});
253+
```
254+
255+
`wdaMjpegUrl` 可以使用独立的协议、主机、端口、路径和查询参数。协议须为 HTTP(S),地址中不能包含用户名、密码或片段。`wdaMjpegUrl` 与 `wdaMjpegPort` 不能同时设置。
256+
257+
如果使用本地端口转发,可以设置 `wdaMjpegPort`,默认值为 `9100`。画面流不可用时,Playground 会退回到截图轮询。
258+
236259
### 如何在 Playground 中获得更流畅的实时画面?
237260

238261
Playground 的画面预览支持两种模式:

‎apps/site/docs/zh/reference/index.mdx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2457,9 +2457,11 @@ const device = new IOSDevice({
24572457

24582458
- `wdaPort?: number` —— WebDriverAgent 端口,默认 `8100`。
24592459
- `wdaHost?: string` —— WebDriverAgent host,默认 `'localhost'`。
2460+
- `wdaBaseUrl?: string` —— 完整的 HTTP(S) WDA API 基地址,可包含网关路径前缀。不能与 `wdaHost` 或 `wdaPort` 同时设置。
24602461
- `iOSDeviceClassOverride?: string` —— 使用 `agentFromWebDriverAgent()` 或 iOS Playground 时替换默认 `IOSDevice` 的 npm module path。目标模块必须导出 `IOSDevice` class 或 default class。
24612462
- `sessionId?: string` —— 复用已有的 WebDriverAgent session ID。传入后,Midscene 不再创建新的 WDA session;清理时只会从这个外部 WebDriver session 分离,不会删除它。
24622463
- `wdaMjpegPort?: number` —— WDA MJPEG 服务端口,用于实时画面流,默认 `9100`。
2464+
- `wdaMjpegUrl?: string` —— 完整的 HTTP(S) MJPEG 画面流地址,可包含网关路径或查询参数。不能与 `wdaMjpegPort` 同时设置。
24632465
- `wdaMjpegFrameSource?: { enabled?: boolean }` —— 使用 WDA 的 MJPEG stream 作为 `agent.startObserving()` 的连续帧源。默认关闭;关闭时,观察逻辑会退回到连续调用 `screenshotBase64()`。
24642466
- `autoDismissKeyboard?: boolean` —— 文本输入后自动隐藏键盘,默认 `true`。
24652467
- `keyboardTypeDelay?: number` —— 按键间延迟,单位为毫秒。取值必须是有限的非负数。设为正数后,`legacy` 输入会通过 WDA 的 `/wda/keys` 接口逐个 Unicode 码点执行。适用于输入框在快速输入下丢字的场景。
@@ -2470,6 +2472,8 @@ const device = new IOSDevice({
24702472

24712473
- 请确认已开启开发者模式且 WDA 能访问设备;真机转发端口时可借助 `iproxy`。
24722474
- 通过 `wdaHost`/`wdaPort` 可指向远程设备或自建的 WDA。
2475+
- 网关通过路径前缀转发 WDA 时,使用 `wdaBaseUrl`;WDA API 请求和就绪探测都会使用该前缀。
2476+
- 网关提供原生 MJPEG 流时,单独设置 `wdaMjpegUrl`。WDA API 基地址不会决定画面流地址。
24732477
- 多设备并发时,请为每个设备设置不同的 `wdaPort` 和 `wdaMjpegPort`,避免 WDA 命令和 MJPEG stream 端口冲突。
24742478
- 通用交互方法请查阅 [API 参考(通用)](#interaction-methods)。
24752479

‎apps/studio/src/renderer/playground/selectors.ts‎

Lines changed: 106 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type {
22
PlaygroundRuntimeInfo,
33
PlaygroundSessionTarget,
44
} from '@midscene/playground';
5+
import { sha256Hex } from '@midscene/shared/utils';
56
import type {
67
DiscoveredDevice,
78
PlatformDiscoveryError,
@@ -47,6 +48,40 @@ function buildHostPortId(host: string, port: number): string {
4748
return `${host}:${port}`;
4849
}
4950

51+
function iosFormValue(formValues: Record<string, unknown>, key: string) {
52+
const value = formValues[`ios.${key}`] ?? formValues[key];
53+
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
54+
}
55+
56+
function normalizedGatewayUrl(value: string) {
57+
const url = new URL(value);
58+
return url.toString().replace(/\/+$/, '');
59+
}
60+
61+
export function resolveSelectedIosGatewayId(
62+
formValues: Record<string, unknown>,
63+
): string | undefined {
64+
const baseUrl = iosFormValue(formValues, 'baseUrl');
65+
if (!baseUrl) return undefined;
66+
try {
67+
const mjpegUrl = iosFormValue(formValues, 'mjpegUrl');
68+
const sessionId = iosFormValue(formValues, 'sessionId');
69+
const mjpegPort = normalizePort(
70+
formValues['ios.mjpegPort'] ?? formValues.mjpegPort,
71+
);
72+
return `ios-gateway-${sha256Hex(
73+
JSON.stringify({
74+
baseUrl: normalizedGatewayUrl(baseUrl),
75+
mjpegUrl: mjpegUrl ? new URL(mjpegUrl).toString() : undefined,
76+
mjpegPort,
77+
sessionId,
78+
}),
79+
)}`;
80+
} catch {
81+
return undefined;
82+
}
83+
}
84+
5085
/**
5186
* Map any incoming platform string (runtime metadata, form values, desktop
5287
* OS aliases like `macos`) to the canonical `StudioPlatformId`. Exported so
@@ -84,7 +119,7 @@ export function normalizeStudioPlatformId(
84119
* Platforms use different metadata keys for the device id:
85120
* Android / Harmony → metadata.deviceId
86121
* Computer → metadata.displayId
87-
* iOS → metadata.wdaHost + metadata.wdaPort
122+
* iOS → gateway URL digest, or metadata.wdaHost + metadata.wdaPort
88123
*/
89124
export function resolveConnectedDeviceId(
90125
runtimeInfo: PlaygroundRuntimeInfo | null,
@@ -96,6 +131,9 @@ export function resolveConnectedDeviceId(
96131
if (isString(metadata.displayId)) {
97132
return metadata.displayId;
98133
}
134+
if (isString(metadata.wdaGatewayId)) {
135+
return metadata.wdaGatewayId;
136+
}
99137
if (isString(metadata.wdaHost)) {
100138
const wdaPort = normalizePort(metadata.wdaPort);
101139
if (wdaPort !== undefined) {
@@ -108,6 +146,7 @@ export function resolveConnectedDeviceId(
108146
function resolveConnectedSessionValues(
109147
runtimeInfo: PlaygroundRuntimeInfo | null,
110148
platformKey: StudioSidebarPlatformKey,
149+
formValues: Record<string, unknown> = {},
111150
): Record<string, StudioSessionValue> | undefined {
112151
const metadata = runtimeInfo?.metadata || {};
113152

@@ -126,7 +165,20 @@ function resolveConnectedSessionValues(
126165
}
127166
: undefined;
128167
case 'ios': {
168+
if (isString(metadata.wdaGatewayId)) {
169+
return resolveSelectedIosGatewayId(formValues) === metadata.wdaGatewayId
170+
? resolveSelectedSessionValues('ios', formValues)
171+
: undefined;
172+
}
129173
const wdaPort = normalizePort(metadata.wdaPort);
174+
if (
175+
isString(metadata.wdaHost) &&
176+
wdaPort !== undefined &&
177+
resolveSelectedDeviceId({ ...formValues, platformId: 'ios' }) ===
178+
buildHostPortId(metadata.wdaHost, wdaPort)
179+
) {
180+
return resolveSelectedSessionValues('ios', formValues);
181+
}
130182
return isString(metadata.wdaHost) && wdaPort !== undefined
131183
? {
132184
host: metadata.wdaHost,
@@ -155,6 +207,9 @@ export function resolveConnectedDeviceLabel(
155207
}
156208
const deviceId = resolveConnectedDeviceId(runtimeInfo);
157209
if (deviceId) {
210+
if (isString(metadata.wdaGatewayId) && isString(metadata.wdaHost)) {
211+
return `${metadata.wdaHost} (WDA gateway)`;
212+
}
158213
// "Display 1" reads better than a bare numeric id for computer.
159214
return isString(metadata.displayId) && !isString(metadata.deviceId)
160215
? `Display ${deviceId}`
@@ -169,13 +224,11 @@ export function resolveConnectedDeviceLabel(
169224
function buildGenericConnectedDeviceItem(
170225
runtimeInfo: PlaygroundRuntimeInfo | null,
171226
platformKey: StudioSidebarPlatformKey,
227+
formValues: Record<string, unknown>,
172228
): StudioAndroidDeviceItem | null {
173229
const metadata = runtimeInfo?.metadata || {};
174230
const deviceId = resolveConnectedDeviceId(runtimeInfo);
175-
const label = isString(metadata.sessionDisplayName)
176-
? metadata.sessionDisplayName
177-
: deviceId ||
178-
(isString(runtimeInfo?.title) ? runtimeInfo.title : undefined);
231+
const label = resolveConnectedDeviceLabel(runtimeInfo, { emptyLabel: '' });
179232

180233
if (!label) {
181234
return null;
@@ -184,10 +237,17 @@ function buildGenericConnectedDeviceItem(
184237
return {
185238
id: deviceId || `${platformKey}-connected`,
186239
label,
187-
description: deviceId && deviceId !== label ? deviceId : undefined,
240+
description:
241+
deviceId && deviceId !== label && !isString(metadata.wdaGatewayId)
242+
? deviceId
243+
: undefined,
188244
selected: true,
189245
status: 'active',
190-
sessionValues: resolveConnectedSessionValues(runtimeInfo, platformKey),
246+
sessionValues: resolveConnectedSessionValues(
247+
runtimeInfo,
248+
platformKey,
249+
formValues,
250+
),
191251
};
192252
}
193253

@@ -214,6 +274,8 @@ export function resolveSelectedDeviceId(
214274
const selectedPlatform = normalizeStudioPlatformId(formValues.platformId);
215275

216276
if (selectedPlatform === 'ios') {
277+
const gatewayId = resolveSelectedIosGatewayId(formValues);
278+
if (gatewayId) return gatewayId;
217279
const host = isString(formValues['ios.host'])
218280
? formValues['ios.host']
219281
: isString(formValues.host)
@@ -300,13 +362,35 @@ function resolveSelectedSessionValues(
300362
? { deviceId: formValues.deviceId }
301363
: undefined;
302364
case 'ios': {
365+
const baseUrl = iosFormValue(formValues, 'baseUrl');
366+
const mjpegUrl = iosFormValue(formValues, 'mjpegUrl');
367+
const sessionId = iosFormValue(formValues, 'sessionId');
368+
const mjpegPort = normalizePort(
369+
formValues['ios.mjpegPort'] ?? formValues.mjpegPort,
370+
);
371+
if (baseUrl) {
372+
return {
373+
baseUrl,
374+
...(mjpegUrl ? { mjpegUrl } : {}),
375+
...(mjpegPort !== undefined ? { mjpegPort } : {}),
376+
...(sessionId ? { sessionId } : {}),
377+
};
378+
}
303379
const host = isString(formValues['ios.host'])
304380
? formValues['ios.host']
305381
: isString(formValues.host)
306382
? formValues.host
307383
: undefined;
308384
const port = normalizePort(formValues['ios.port'] ?? formValues.port);
309-
return host && port !== undefined ? { host, port } : undefined;
385+
return host && port !== undefined
386+
? {
387+
host,
388+
port,
389+
...(mjpegUrl ? { mjpegUrl } : {}),
390+
...(mjpegPort !== undefined ? { mjpegPort } : {}),
391+
...(sessionId ? { sessionId } : {}),
392+
}
393+
: undefined;
310394
}
311395
default:
312396
return undefined;
@@ -316,8 +400,20 @@ function resolveSelectedSessionValues(
316400
export function buildDeviceSelectionFormValues(
317401
platform: StudioSidebarPlatformKey,
318402
device: Pick<StudioAndroidDeviceItem, 'id' | 'sessionValues'>,
319-
): Record<string, StudioSessionValue> {
403+
): Record<string, StudioSessionValue | null> {
320404
if (device.sessionValues) {
405+
if (platform === 'ios') {
406+
return {
407+
platformId: platform,
408+
'ios.host': null,
409+
'ios.port': null,
410+
'ios.baseUrl': null,
411+
'ios.mjpegUrl': null,
412+
'ios.mjpegPort': null,
413+
'ios.sessionId': null,
414+
...prefixSessionValues(platform, device.sessionValues),
415+
};
416+
}
321417
return {
322418
platformId: platform,
323419
...prefixSessionValues(platform, device.sessionValues),
@@ -505,6 +601,7 @@ export function buildStudioSidebarDeviceBuckets({
505601
const connectedItem = buildGenericConnectedDeviceItem(
506602
runtimeInfo,
507603
runtimePlatformKey,
604+
formValues,
508605
);
509606

510607
if (connectedItem) {

0 commit comments

Comments
 (0)