@phone-use/sdk 0.1.1 → 0.3.0

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@phone-use/sdk",
3
- "version": "0.1.1",
4
- "description": "Typed mobile-device SDK Device, backends, errors, actions",
3
+ "version": "0.3.0",
4
+ "description": "Typed mobile-device SDK \u2014 Device, backends, errors, actions",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
7
7
  "sideEffects": false,
@@ -0,0 +1,202 @@
1
+ import { writeFile } from 'node:fs/promises';
2
+ import { BaseDeviceBackend } from '../backend.ts';
3
+ import {
4
+ ALL_CAPABILITIES,
5
+ type AlertAction,
6
+ type BackendAlertResult,
7
+ type Capability,
8
+ type OpenAppResult,
9
+ type PressTarget,
10
+ type ScrollDirection,
11
+ type Snapshot,
12
+ } from '../device.ts';
13
+ import { PhoneUseError } from '../errors.ts';
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // CloudSandboxBackend: drives a phone-use cloud sandbox (a simulator on a
17
+ // remote worker) over the worker's sandbox-scoped RPC endpoint. This is the
18
+ // client half of packages/cloud's worker protocol; it lives in the SDK so the
19
+ // CLI/MCP/agent can attach to a sandbox without depending on @phone-use/cloud
20
+ // (which also contains the server side and is not published).
21
+ //
22
+ // Wire protocol (shared with packages/cloud — keep in sync):
23
+ // POST <endpoint>/rpc {method, args} -> {ok:true,result} | {ok:false,error}
24
+ // ---------------------------------------------------------------------------
25
+
26
+ export type SandboxRpcMethod =
27
+ | 'snapshot'
28
+ | 'screenshot'
29
+ | 'press'
30
+ | 'longPress'
31
+ | 'fill'
32
+ | 'typeText'
33
+ | 'pressKey'
34
+ | 'scroll'
35
+ | 'pan'
36
+ | 'installApp'
37
+ | 'waitForText'
38
+ | 'systemAlert'
39
+ | 'home'
40
+ | 'back'
41
+ | 'openApp'
42
+ | 'listApps'
43
+ | 'closeSession';
44
+
45
+ export type SandboxRpcRequest = { method: SandboxRpcMethod; args: unknown[] };
46
+ export type SandboxRpcSuccess = { ok: true; result: unknown };
47
+ export type SandboxRpcFailure = {
48
+ ok: false;
49
+ error: { message: string; code?: string | undefined; retryable?: boolean | undefined };
50
+ };
51
+ export type SandboxRpcResponse = SandboxRpcSuccess | SandboxRpcFailure;
52
+
53
+ export type CloudSandboxBackendOptions = {
54
+ endpoint: string;
55
+ token: string;
56
+ capabilities?: Capability[] | undefined;
57
+ fetch?: typeof globalThis.fetch | undefined;
58
+ };
59
+
60
+ export class CloudSandboxBackend extends BaseDeviceBackend {
61
+ private readonly endpoint: string;
62
+ private readonly token: string;
63
+ private readonly fetchImpl: typeof globalThis.fetch;
64
+
65
+ constructor(opts: CloudSandboxBackendOptions) {
66
+ super('phone-use-cloud', opts.capabilities ?? ALL_CAPABILITIES);
67
+ this.endpoint = opts.endpoint.replace(/\/+$/, '');
68
+ this.token = opts.token;
69
+ this.fetchImpl = opts.fetch ?? globalThis.fetch;
70
+ }
71
+
72
+ private async rpc<T>(method: SandboxRpcMethod, ...args: unknown[]): Promise<T> {
73
+ const response = await this.fetchImpl(`${this.endpoint}/rpc`, {
74
+ method: 'POST',
75
+ headers: {
76
+ authorization: `Bearer ${this.token}`,
77
+ 'content-type': 'application/json',
78
+ },
79
+ body: JSON.stringify({ method, args } satisfies SandboxRpcRequest),
80
+ });
81
+ const body = (await response.json().catch(() => ({}))) as
82
+ | SandboxRpcResponse
83
+ | { error?: string; message?: string };
84
+ if (!response.ok || !('ok' in body)) {
85
+ const message =
86
+ ('error' in body && typeof body.error === 'string' ? body.error : undefined) ??
87
+ ('message' in body ? body.message : undefined) ??
88
+ `HTTP ${response.status}`;
89
+ throw new PhoneUseError(message, { code: 'UNKNOWN', retryable: false });
90
+ }
91
+ if (!body.ok) {
92
+ throw new PhoneUseError(body.error.message, {
93
+ code: normalizeErrorCode(body.error.code),
94
+ retryable: body.error.retryable ?? false,
95
+ });
96
+ }
97
+ return body.result as T;
98
+ }
99
+
100
+ override snapshot(opts?: { interactiveOnly?: boolean; depth?: number }): Promise<Snapshot> {
101
+ return this.rpc('snapshot', opts);
102
+ }
103
+
104
+ override async screenshot(opts: { path: string; overlayRefs?: boolean }): Promise<{ path: string }> {
105
+ const result = await this.rpc<{ base64: string }>('screenshot', {
106
+ overlayRefs: opts.overlayRefs,
107
+ });
108
+ await writeFile(opts.path, Buffer.from(result.base64, 'base64'));
109
+ return { path: opts.path };
110
+ }
111
+
112
+ override press(target: PressTarget): Promise<void> {
113
+ return this.rpc('press', target);
114
+ }
115
+ override longPress(ref: string, durationMs?: number): Promise<void> {
116
+ return this.rpc('longPress', ref, durationMs);
117
+ }
118
+ override fill(ref: string, text: string): Promise<void> {
119
+ return this.rpc('fill', ref, text);
120
+ }
121
+ override typeText(text: string): Promise<void> {
122
+ return this.rpc('typeText', text);
123
+ }
124
+ override pressKey(key: 'return'): Promise<void> {
125
+ return this.rpc('pressKey', key);
126
+ }
127
+ override scroll(direction: ScrollDirection): Promise<void> {
128
+ return this.rpc('scroll', direction);
129
+ }
130
+ override pan(x: number, y: number, dx: number, dy: number, durationMs?: number): Promise<void> {
131
+ return this.rpc('pan', x, y, dx, dy, durationMs);
132
+ }
133
+ override waitForText(text: string, timeoutMs?: number): Promise<void> {
134
+ return this.rpc('waitForText', text, timeoutMs);
135
+ }
136
+ override systemAlert(action: AlertAction): Promise<BackendAlertResult> {
137
+ return this.rpc('systemAlert', action);
138
+ }
139
+ override home(): Promise<void> {
140
+ return this.rpc('home');
141
+ }
142
+ override back(): Promise<void> {
143
+ return this.rpc('back');
144
+ }
145
+ override openApp(opts: { app?: string; url?: string; relaunch?: boolean }): Promise<OpenAppResult> {
146
+ return this.rpc('openApp', opts);
147
+ }
148
+ override listApps(): Promise<string[]> {
149
+ return this.rpc('listApps');
150
+ }
151
+ override closeSession(): Promise<void> {
152
+ return this.rpc('closeSession');
153
+ }
154
+
155
+ /** Upload a zipped .app bundle (base64) and install it on the sandbox device. */
156
+ installApp(base64Zip: string): Promise<{ installed?: string }> {
157
+ return this.rpc('installApp', base64Zip);
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Build a CloudSandboxBackend from explicit options or the environment:
163
+ * PHONE_USE_SANDBOX_URL + PHONE_USE_SANDBOX_TOKEN (printed by
164
+ * `phone-use sandbox env <id>`).
165
+ */
166
+ export function createCloudSandboxBackend(config?: Partial<CloudSandboxBackendOptions>): CloudSandboxBackend {
167
+ const endpoint = config?.endpoint ?? process.env.PHONE_USE_SANDBOX_URL;
168
+ const token = config?.token ?? process.env.PHONE_USE_SANDBOX_TOKEN;
169
+ if (!endpoint || !token) {
170
+ throw new PhoneUseError(
171
+ 'cloud sandbox backend needs PHONE_USE_SANDBOX_URL and PHONE_USE_SANDBOX_TOKEN (see `phone-use sandbox env <id>`)',
172
+ { code: 'BACKEND_NOT_FOUND', retryable: false },
173
+ );
174
+ }
175
+ return new CloudSandboxBackend({ ...config, endpoint, token });
176
+ }
177
+
178
+ function normalizeErrorCode(
179
+ code: string | undefined,
180
+ ):
181
+ | 'DEVICE_NOT_FOUND'
182
+ | 'DEVICE_IN_USE'
183
+ | 'SESSION_NOT_FOUND'
184
+ | 'TIMEOUT'
185
+ | 'ACTION_FAILED'
186
+ | 'UNSUPPORTED_CAPABILITY'
187
+ | 'BACKEND_NOT_FOUND'
188
+ | 'ABORTED'
189
+ | 'UNKNOWN' {
190
+ const codes = new Set([
191
+ 'DEVICE_NOT_FOUND',
192
+ 'DEVICE_IN_USE',
193
+ 'SESSION_NOT_FOUND',
194
+ 'TIMEOUT',
195
+ 'ACTION_FAILED',
196
+ 'UNSUPPORTED_CAPABILITY',
197
+ 'BACKEND_NOT_FOUND',
198
+ 'ABORTED',
199
+ 'UNKNOWN',
200
+ ]);
201
+ return codes.has(code ?? '') ? (code as ReturnType<typeof normalizeErrorCode>) : 'UNKNOWN';
202
+ }
@@ -0,0 +1,205 @@
1
+ import { BaseDeviceBackend } from '../backend.ts';
2
+ import type {
3
+ AlertAction,
4
+ BackendAlertResult,
5
+ Capability,
6
+ OpenAppResult,
7
+ PressTarget,
8
+ ScrollDirection,
9
+ Snapshot,
10
+ } from '../device.ts';
11
+ import { ActionFailedError, DeviceNotFoundError, TimeoutError } from '../errors.ts';
12
+
13
+ /**
14
+ * A device-runner is addressed purely by URL, so it takes no DeviceConfig
15
+ * platform/udid selection — only where to reach the runner and how to auth.
16
+ */
17
+ export type DeviceRunnerConfig = {
18
+ endpoint?: string | undefined;
19
+ token?: string | undefined;
20
+ timeoutMs?: number | undefined;
21
+ };
22
+
23
+ const RUNNER_CAPABILITIES: readonly Capability[] = [
24
+ 'snapshot',
25
+ 'screenshot',
26
+ 'press',
27
+ 'fill',
28
+ 'type',
29
+ 'scroll',
30
+ 'pan',
31
+ 'openApp',
32
+ 'home',
33
+ ];
34
+
35
+ /**
36
+ * Backend that speaks to an on-device runner: an XCTest-hosted JSON-RPC server
37
+ * running ON the iPhone itself, which holds the automation privileges iOS
38
+ * denies to ordinary apps.
39
+ *
40
+ * The endpoint is just a URL, so the same backend serves every topology:
41
+ * - `http://127.0.0.1:45678` — port-forwarded from a paired host
42
+ * - `http://<phone-ip>:45678` — straight over the LAN / tailnet
43
+ * - `https://relay.example/d/<id>` — the runner dials out to a cloud relay,
44
+ * which is what lets an agent anywhere drive the phone with no inbound
45
+ * ports and no Mac in the loop.
46
+ *
47
+ * The wire format matches the shape proven by rounak/PhoneAgent: newline-free
48
+ * JSON request/response over HTTP POST, one method per call.
49
+ */
50
+ export class DeviceRunnerBackend extends BaseDeviceBackend {
51
+ readonly #endpoint: string;
52
+ readonly #token: string | undefined;
53
+ readonly #timeoutMs: number;
54
+
55
+ constructor(config?: DeviceRunnerConfig) {
56
+ // BaseDeviceBackend owns backendName/capabilities — set them via super()
57
+ // rather than redeclaring the fields.
58
+ super('device-runner', RUNNER_CAPABILITIES);
59
+ const endpoint = config?.endpoint ?? process.env.PHONE_USE_RUNNER_URL ?? 'http://127.0.0.1:45678';
60
+ this.#endpoint = endpoint.replace(/\/+$/, '');
61
+ this.#token = config?.token ?? process.env.PHONE_USE_RUNNER_TOKEN;
62
+ this.#timeoutMs = config?.timeoutMs ?? 30_000;
63
+ }
64
+
65
+ async #rpc<T>(method: string, params: Record<string, unknown> = {}): Promise<T> {
66
+ const controller = new AbortController();
67
+ const timer = setTimeout(() => controller.abort(), this.#timeoutMs);
68
+ let res: Response;
69
+ try {
70
+ res = await fetch(this.#endpoint, {
71
+ method: 'POST',
72
+ headers: {
73
+ 'content-type': 'application/json',
74
+ ...(this.#token ? { authorization: `Bearer ${this.#token}` } : {}),
75
+ },
76
+ body: JSON.stringify({ jsonrpc: '2.0', id: Date.now(), method, params }),
77
+ signal: controller.signal,
78
+ });
79
+ } catch (cause) {
80
+ if (controller.signal.aborted) {
81
+ throw new TimeoutError(`runner did not answer ${method} within ${this.#timeoutMs}ms`);
82
+ }
83
+ throw new DeviceNotFoundError(
84
+ `cannot reach the on-device runner at ${this.#endpoint} — is it activated on the phone?`,
85
+ { cause },
86
+ );
87
+ } finally {
88
+ clearTimeout(timer);
89
+ }
90
+
91
+ if (!res.ok) {
92
+ throw new ActionFailedError(`runner returned HTTP ${res.status} for ${method}`);
93
+ }
94
+ const body = (await res.json()) as { result?: T; error?: { message?: string } };
95
+ if (body.error) {
96
+ throw new ActionFailedError(body.error.message ?? `runner rejected ${method}`);
97
+ }
98
+ return body.result as T;
99
+ }
100
+
101
+ override async snapshot(opts?: {
102
+ interactiveOnly?: boolean | undefined;
103
+ depth?: number | undefined;
104
+ }): Promise<Snapshot> {
105
+ // The runner speaks its own compact wire shape; map it onto the SDK's
106
+ // Snapshot contract so every consumer (CLI, MCP, agent, bench) is unaware
107
+ // it is talking to a phone rather than a simulator.
108
+ const wire = await this.#rpc<{
109
+ app?: string;
110
+ elements: Array<{
111
+ ref: string;
112
+ role?: string;
113
+ label?: string;
114
+ value?: string;
115
+ enabled?: boolean;
116
+ rect?: { x: number; y: number; w: number; h: number };
117
+ }>;
118
+ }>('get_tree', {
119
+ interactiveOnly: opts?.interactiveOnly ?? false,
120
+ depth: opts?.depth,
121
+ });
122
+ return {
123
+ appBundleId: wire.app,
124
+ appName: wire.app,
125
+ nodes: (wire.elements ?? []).map((e) => ({
126
+ ref: e.ref,
127
+ role: e.role,
128
+ type: e.role,
129
+ label: e.label,
130
+ value: e.value,
131
+ enabled: e.enabled,
132
+ rect: e.rect ? { x: e.rect.x, y: e.rect.y, width: e.rect.w, height: e.rect.h } : undefined,
133
+ })),
134
+ };
135
+ }
136
+
137
+ override async screenshot(opts: {
138
+ path: string;
139
+ overlayRefs?: boolean | undefined;
140
+ }): Promise<{ path: string }> {
141
+ const { base64 } = await this.#rpc<{ base64: string }>('get_screen_image', {
142
+ overlayRefs: opts.overlayRefs ?? false,
143
+ });
144
+ const { writeFile } = await import('node:fs/promises');
145
+ await writeFile(opts.path, Buffer.from(base64, 'base64'));
146
+ return { path: opts.path };
147
+ }
148
+
149
+ override async press(target: PressTarget): Promise<void> {
150
+ // PressTarget is {ref} | {x,y} — never a bare string, so narrow on the key.
151
+ if ('ref' in target) {
152
+ await this.#rpc('tap_element', { ref: target.ref });
153
+ return;
154
+ }
155
+ await this.#rpc('tap', { x: target.x, y: target.y });
156
+ }
157
+
158
+ override async fill(ref: string, text: string): Promise<void> {
159
+ await this.#rpc('enter_text', { ref, text, replace: true });
160
+ }
161
+
162
+ override async typeText(text: string): Promise<void> {
163
+ await this.#rpc('enter_text', { text, replace: false });
164
+ }
165
+
166
+ override async scroll(direction: ScrollDirection): Promise<void> {
167
+ await this.#rpc('scroll', { direction });
168
+ }
169
+
170
+ override async pan(x: number, y: number, dx: number, dy: number, durationMs = 300): Promise<void> {
171
+ await this.#rpc('swipe', { x, y, dx, dy, durationMs });
172
+ }
173
+
174
+ override async home(): Promise<void> {
175
+ await this.#rpc('home');
176
+ }
177
+
178
+ override async openApp(opts: {
179
+ app?: string | undefined;
180
+ url?: string | undefined;
181
+ relaunch?: boolean | undefined;
182
+ }): Promise<OpenAppResult> {
183
+ return this.#rpc<OpenAppResult>('open_app', {
184
+ app: opts.app,
185
+ url: opts.url,
186
+ relaunch: opts.relaunch ?? false,
187
+ });
188
+ }
189
+
190
+ override async systemAlert(action: AlertAction): Promise<BackendAlertResult> {
191
+ return this.#rpc<BackendAlertResult>('alert', { action });
192
+ }
193
+
194
+ override async closeSession(): Promise<void> {
195
+ // The runner outlives any single client; nothing to tear down.
196
+ }
197
+
198
+ /** Liveness probe used by `phone-use doctor` and the relay health check. */
199
+ async ping(): Promise<{ ok: boolean; ios?: string; device?: string }> {
200
+ return this.#rpc('get_context');
201
+ }
202
+ }
203
+
204
+ export const createDeviceRunnerBackend = (config?: DeviceRunnerConfig): DeviceRunnerBackend =>
205
+ new DeviceRunnerBackend(config);
package/src/index.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  * deliberately not re-exported here.
8
8
  */
9
9
  /** The published package version (kept in sync with package.json by the release flow). */
10
- export const VERSION = '0.1.1';
10
+ export const VERSION = '0.3.0';
11
11
 
12
12
  export {
13
13
  type Action,
@@ -31,6 +31,15 @@ export {
31
31
  registerBackend,
32
32
  } from './backend.ts';
33
33
  export { createAgentDeviceBackend } from './backends/agent-device.ts';
34
+ export {
35
+ CloudSandboxBackend,
36
+ type CloudSandboxBackendOptions,
37
+ createCloudSandboxBackend,
38
+ type SandboxRpcMethod,
39
+ type SandboxRpcRequest,
40
+ type SandboxRpcResponse,
41
+ } from './backends/cloud-sandbox.ts';
42
+ export { createDeviceRunnerBackend, DeviceRunnerBackend } from './backends/device-runner.ts';
34
43
  export { type IosConnectOptions, type IosLaunchOptions, ios } from './backends/ios.ts';
35
44
  export type { AndroidDeviceConfig, CommonDeviceConfig, DeviceConfig, IosDeviceConfig } from './config.ts';
36
45
  export type {