@phone-use/sdk 0.4.0 → 0.5.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.
@@ -42,7 +42,18 @@ export type SandboxRpcMethod =
42
42
  | 'back'
43
43
  | 'openApp'
44
44
  | 'listApps'
45
- | 'closeSession';
45
+ | 'closeSession'
46
+ | 'checkpoint'
47
+ | 'restoreCheckpoint'
48
+ | 'listCheckpoints'
49
+ | 'deleteCheckpoint';
50
+
51
+ /** One saved device state, as reported by the worker. */
52
+ export type SandboxCheckpointInfo = {
53
+ id: string;
54
+ label?: string | undefined;
55
+ createdAt: string;
56
+ };
46
57
 
47
58
  /** Body of `POST <endpoint>/rpc`: one verb and its positional arguments. */
48
59
  export type SandboxRpcRequest = { method: SandboxRpcMethod; args: unknown[] };
@@ -162,6 +173,36 @@ export class CloudSandboxBackend extends BaseDeviceBackend {
162
173
  return this.rpc('closeSession');
163
174
  }
164
175
 
176
+ // -------------------------------------------------------------------------
177
+ // Checkpoints: save/restore the WHOLE device state (apps, logins, photos,
178
+ // settings). Not a UI snapshot — see `snapshot` for the accessibility tree.
179
+ // The device is briefly unavailable while a checkpoint saves or restores
180
+ // (shutdown → clone → boot on the worker); verbs issued meanwhile fail and
181
+ // should be retried after the checkpoint call resolves.
182
+ // -------------------------------------------------------------------------
183
+
184
+ /** Save the device's current state. Returns the checkpoint's id. */
185
+ checkpoint(opts?: { label?: string }): Promise<SandboxCheckpointInfo> {
186
+ return this.rpc('checkpoint', opts);
187
+ }
188
+
189
+ /** Restore a checkpoint by id or label. The checkpoint survives — restore as
190
+ * often as needed. Device identity may change server-side; the sandbox
191
+ * endpoint/token stay valid. */
192
+ restoreCheckpoint(ref: string): Promise<{ checkpointId: string }> {
193
+ return this.rpc('restoreCheckpoint', ref);
194
+ }
195
+
196
+ /** List this sandbox's checkpoints. */
197
+ listCheckpoints(): Promise<SandboxCheckpointInfo[]> {
198
+ return this.rpc('listCheckpoints');
199
+ }
200
+
201
+ /** Delete a checkpoint by id or label. */
202
+ deleteCheckpoint(ref: string): Promise<{ deleted: boolean }> {
203
+ return this.rpc('deleteCheckpoint', ref);
204
+ }
205
+
165
206
  /** Upload a zipped .app bundle (base64) and install it on the sandbox device. */
166
207
  /**
167
208
  * Upload a zipped .app as a raw stream. Prefer this over {@link installApp}:
@@ -186,9 +227,7 @@ export class CloudSandboxBackend extends BaseDeviceBackend {
186
227
  const bytes = await zip.arrayBuffer();
187
228
  return this.installApp(Buffer.from(bytes).toString('base64'));
188
229
  }
189
- const body = (await response.json().catch(() => ({}))) as
190
- | SandboxRpcResponse
191
- | { error?: string };
230
+ const body = (await response.json().catch(() => ({}))) as SandboxRpcResponse | { error?: string };
192
231
  if (!response.ok || !('ok' in body)) {
193
232
  const message =
194
233
  ('error' in body && typeof body.error === 'string' ? body.error : undefined) ??
package/src/index.ts CHANGED
@@ -1,13 +1,13 @@
1
1
  /**
2
2
  * @phone-use/sdk — the device runtime SDK: engine-as-object lifecycle
3
- * (ios.launch/connect → Device), Device backends, config, errors, capabilities,
3
+ * (ios.launch/connect and android.launch/connect → Device), Device backends, config, errors, capabilities,
4
4
  * and the action verb surface.
5
5
  *
6
6
  * The test double (FakeBackend) lives on the "@phone-use/sdk/testing" subpath,
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.4.0';
10
+ export const VERSION = '0.5.0';
11
11
 
12
12
  export {
13
13
  type Action,
@@ -31,10 +31,23 @@ export {
31
31
  registerBackend,
32
32
  } from './backend.ts';
33
33
  export { createAgentDeviceBackend } from './backends/agent-device.ts';
34
+ export {
35
+ AndroidBackend,
36
+ type AndroidBackendOptions,
37
+ type AndroidConnectOptions,
38
+ type AndroidDeviceInfo,
39
+ type AndroidLaunchOptions,
40
+ type AndroidListOptions,
41
+ android,
42
+ type BinaryExecRunner,
43
+ createAndroidBackend,
44
+ type EmulatorProcess,
45
+ } from './backends/android.ts';
34
46
  export {
35
47
  CloudSandboxBackend,
36
48
  type CloudSandboxBackendOptions,
37
49
  createCloudSandboxBackend,
50
+ type SandboxCheckpointInfo,
38
51
  type SandboxRpcFailure,
39
52
  type SandboxRpcMethod,
40
53
  type SandboxRpcRequest,
@@ -97,5 +110,7 @@ export { SecretStore } from './secrets.ts';
97
110
  // of the *published* dist, where the barrel is the entry).
98
111
  import { registerBackend as _register } from './backend.ts';
99
112
  import { createAgentDeviceBackend as _createAd } from './backends/agent-device.ts';
113
+ import { createAndroidBackend as _createAndroid } from './backends/android.ts';
100
114
 
101
115
  _register('agent-device', _createAd);
116
+ _register('android-adb', _createAndroid);
package/src/lifecycle.ts CHANGED
@@ -21,8 +21,8 @@ export type DeviceStatus = 'running' | 'closed';
21
21
  /**
22
22
  * The Device lifecycle handle: id, pinned backend, close/dispose, idle lease +
23
23
  * reaper — plus the action verb surface layered onto the same type.
24
- * `ios.launch()` and `ios.connect()` return it; the future android engine will
25
- * share `createDeviceHandle`.
24
+ * `ios.launch()`/`ios.connect()` and `android.launch()`/`android.connect()`
25
+ * all return it, built over `createDeviceHandle`.
26
26
  */
27
27
  export interface Device {
28
28
  /** udid (iOS) / serial (Android). */
@@ -177,7 +177,7 @@ export type CreateDeviceHandleOptions = {
177
177
 
178
178
  /**
179
179
  * Assemble a Device handle over a backend: lease/reaper, verb surface,
180
- * close/dispose semantics. Engine authors (ios here, android in item 7b,
180
+ * close/dispose semantics. Engine authors (ios and android here,
181
181
  * phone-backend-* third parties) build on this; tests fabricate devices with
182
182
  * it over a FakeBackend.
183
183
  */
package/src/observe.ts CHANGED
@@ -94,6 +94,8 @@ function keptViewportNodes(nodes: SnapshotNode[]): {
94
94
  kept: SnapshotNode[];
95
95
  above: number;
96
96
  below: number;
97
+ aboveNames: string[];
98
+ belowNames: string[];
97
99
  suppressFocused: boolean;
98
100
  vw: number;
99
101
  } {
@@ -109,23 +111,47 @@ function keptViewportNodes(nodes: SnapshotNode[]): {
109
111
  const kept: SnapshotNode[] = [];
110
112
  let above = 0;
111
113
  let below = 0;
114
+ // Name the actionable controls hiding off-screen instead of only counting
115
+ // them. Measured failure (AndroidWorld MarkorCreateNote): the create-note
116
+ // FAB sat below the fold, the render said only "[9 elements below]", and the
117
+ // agent burned its whole budget guessing labels (`tap "+"`) for a button it
118
+ // was never told existed. Tappable roles only, capped, so a long list view
119
+ // doesn't flood the render with row labels.
120
+ const aboveNames: string[] = [];
121
+ const belowNames: string[] = [];
122
+ const controlName = (n: SnapshotNode): string | undefined => {
123
+ const role = n.role ?? n.type ?? '';
124
+ if (!TAPPABLE.has(role) || role === 'StaticText' || role === 'Cell') return undefined;
125
+ const label = (n.label ?? n.identifier ?? '').trim();
126
+ return label || undefined;
127
+ };
112
128
  for (const n of nodes) {
113
129
  if (isNoise(n)) continue;
114
130
  if (!intersectsViewport(n.rect, vw, vh)) {
115
- if (n.rect && n.rect.y >= vh) below += 1;
116
- else above += 1;
131
+ const name = controlName(n);
132
+ if (n.rect && n.rect.y >= vh) {
133
+ below += 1;
134
+ if (name && belowNames.length < 5 && !belowNames.includes(name)) belowNames.push(name);
135
+ } else {
136
+ above += 1;
137
+ if (name && aboveNames.length < 5 && !aboveNames.includes(name)) aboveNames.push(name);
138
+ }
117
139
  continue;
118
140
  }
119
141
  kept.push(n);
120
142
  }
121
- return { kept, above, below, suppressFocused, vw };
143
+ return { kept, above, below, aboveNames, belowNames, suppressFocused, vw };
122
144
  }
123
145
 
124
146
  function compressNodes(nodes: SnapshotNode[]): string {
125
- const { kept, above, below, suppressFocused, vw } = keptViewportNodes(nodes);
147
+ const { kept, above, below, aboveNames, belowNames, suppressFocused, vw } = keptViewportNodes(nodes);
126
148
  const lines = kept.map((n) => formatNode(n, { suppressFocused, vw }));
127
- if (above > 0) lines.unshift(`[${above} elements above the viewport — scroll up to reach them]`);
128
- if (below > 0) lines.push(`[${below} elements below the viewport — scroll down to reach them]`);
149
+ const controls = (names: string[]) =>
150
+ names.length ? ` controls there: ${names.map((n) => `"${n}"`).join(', ')}` : '';
151
+ if (above > 0)
152
+ lines.unshift(`[${above} elements above the viewport — scroll up to reach them${controls(aboveNames)}]`);
153
+ if (below > 0)
154
+ lines.push(`[${below} elements below the viewport — scroll down to reach them${controls(belowNames)}]`);
129
155
  return lines.join('\n');
130
156
  }
131
157
 
@@ -458,7 +484,9 @@ export class DeviceCore {
458
484
  */
459
485
  renderObservation(full = false): string {
460
486
  const app = this.lastApp.app;
461
- const { kept, above, below, suppressFocused, vw } = keptViewportNodes(this.cachedNodes);
487
+ const { kept, above, below, aboveNames, belowNames, suppressFocused, vw } = keptViewportNodes(
488
+ this.cachedNodes,
489
+ );
462
490
  const keys = kept.map(elementKey);
463
491
  const lineByKey = new Map<string, string>();
464
492
  for (const n of kept) lineByKey.set(elementKey(n), formatNode(n, { suppressFocused, vw }));
@@ -489,8 +517,14 @@ export class DeviceCore {
489
517
  // Full render + refresh the baseline.
490
518
  this.lastRender = { app, keys, lineByKey };
491
519
  const lines = kept.map((n) => formatNode(n, { suppressFocused, vw }));
492
- if (above > 0) lines.unshift(`[${above} elements above the viewport — scroll up to reach them]`);
493
- if (below > 0) lines.push(`[${below} elements below the viewport — scroll down to reach them]`);
520
+ const controls = (names: string[]) =>
521
+ names.length ? ` controls there: ${names.map((n) => `"${n}"`).join(', ')}` : '';
522
+ if (above > 0)
523
+ lines.unshift(
524
+ `[${above} elements above the viewport — scroll up to reach them${controls(aboveNames)}]`,
525
+ );
526
+ if (below > 0)
527
+ lines.push(`[${below} elements below the viewport — scroll down to reach them${controls(belowNames)}]`);
494
528
  return lines.join('\n');
495
529
  }
496
530