droidline 0.0.0-stage → 0.1.1

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/README.md CHANGED
@@ -1,3 +1,39 @@
1
- # Temporary Holding Version
1
+ # droidline
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Node.js SDK for [Droidline](https://droidline.dev). Control Android phones from your code with one-line commands, without ADB.
4
+
5
+ The SDK talks to the Droidline server on your PC (`droidline serve`, port 8780). The server and the phone do the waiting, retries and fallbacks; this package sends commands and turns errors into rejected promises. Node 18+, no dependencies, ES modules with TypeScript types.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ npm install droidline
11
+ ```
12
+
13
+ ## Example
14
+
15
+ ```js
16
+ import { connect } from "droidline";
17
+
18
+ const d = await connect();
19
+ await d.launch("com.kakao.talk");
20
+ await d.dump("screen.json");
21
+ await d.touchById("com.kakao.talk:id/login");
22
+ await d.input("id", "com.kakao.talk:id/email", "knife");
23
+ await d.sendkey("enter");
24
+ if (await d.exists("text", "광고 닫기")) {
25
+ await d.touch("text", "광고 닫기");
26
+ }
27
+ await d.intent("android.settings.WIFI_SETTINGS"); // open a Settings page by its intent action
28
+ await d.waitIdle();
29
+ ```
30
+
31
+ Method names match the protocol (`long_tap`), and each has a camelCase alias (`longTap`). Optional parameters go by position or in an options object as the last argument: `d.wait("text", "완료", { timeout: 30 })`.
32
+
33
+ `connect()` uses the only online phone. With several, pass an ID or name: `connect("shelf-01")`, or use `new Droidline().device("shelf-01")` for each. `DROIDLINE_HOST`, `DROIDLINE_PORT`, `DROIDLINE_TOKEN` and `DROIDLINE_DEVICE` set the defaults.
34
+
35
+ A failed command rejects with a `DroidlineError` that has `code`, `retryable` and the server's extra fields in `data`.
36
+
37
+ The process exits when your script finishes, unless a notification callback is registered. `d.close()` closes the connection early.
38
+
39
+ Docs: https://droidline.dev/docs
package/dist/args.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ export type Params = Record<string, unknown>;
2
+ /**
3
+ * Maps a generated method's arguments onto named params: the required ones, then optional
4
+ * ones by position, then a trailing plain object read as options. Undefined values are dropped
5
+ * so the server fills its own defaults.
6
+ */
7
+ export declare function collect(cmd: string, args: readonly unknown[], required: readonly string[], positional: readonly string[]): Params;
package/dist/args.js ADDED
@@ -0,0 +1,32 @@
1
+ function isOptions(value) {
2
+ if (typeof value !== "object" || value === null || Array.isArray(value))
3
+ return false;
4
+ const proto = Object.getPrototypeOf(value);
5
+ return proto === Object.prototype || proto === null;
6
+ }
7
+ /**
8
+ * Maps a generated method's arguments onto named params: the required ones, then optional
9
+ * ones by position, then a trailing plain object read as options. Undefined values are dropped
10
+ * so the server fills its own defaults.
11
+ */
12
+ export function collect(cmd, args, required, positional) {
13
+ const params = {};
14
+ required.forEach((name, i) => {
15
+ if (args[i] !== undefined)
16
+ params[name] = args[i];
17
+ });
18
+ const rest = args.slice(required.length);
19
+ const options = rest.length > 0 && isOptions(rest[rest.length - 1]) ? rest.pop() : {};
20
+ if (rest.length > positional.length) {
21
+ throw new TypeError(`${cmd}() takes at most ${required.length + positional.length} arguments before the options object`);
22
+ }
23
+ positional.forEach((name, i) => {
24
+ if (rest[i] !== undefined)
25
+ params[name] = rest[i];
26
+ });
27
+ for (const [name, value] of Object.entries(options)) {
28
+ if (value !== undefined)
29
+ params[name] = value;
30
+ }
31
+ return params;
32
+ }
@@ -0,0 +1,130 @@
1
+ import type { Params } from "./args.js";
2
+ import { Element } from "./element.js";
3
+ import { ClientCommands, DeviceCommands, type Notification, type ReturnKind } from "./generated.js";
4
+ export declare const DEFAULT_HOST = "127.0.0.1";
5
+ export declare const DEFAULT_PORT = 8780;
6
+ export interface ClientOptions {
7
+ host?: string;
8
+ port?: number;
9
+ token?: string;
10
+ }
11
+ /** One request in the history kept by each client, oldest first. */
12
+ export interface HistoryEntry {
13
+ time: number;
14
+ device: string | undefined;
15
+ cmd: string;
16
+ params: Params;
17
+ ok: boolean;
18
+ error: string | null;
19
+ ms: number;
20
+ }
21
+ export interface LeaseOptions extends ClientOptions {
22
+ /** This phone, by ID or name. Omit to take any free one. */
23
+ device?: string;
24
+ /** Seconds to wait for a phone to become free. @default 30 */
25
+ wait?: number;
26
+ /** The lease ends after this many seconds without a command. @default 300 */
27
+ ttl?: number;
28
+ /** Only phones with at least this Android API level. */
29
+ min_sdk?: number;
30
+ /** Only phones whose model contains this text. */
31
+ model?: string;
32
+ }
33
+ export interface ConnectOptions extends ClientOptions {
34
+ /** Device ID or name. Omit to use DROIDLINE_DEVICE, else the only online phone. */
35
+ device?: string;
36
+ }
37
+ export interface NotificationFilter {
38
+ /** Only from this app. */
39
+ package?: string;
40
+ /** Only if title or text contains this. */
41
+ textContains?: string;
42
+ /** Called with the notification, unless the callback is passed as the second argument. */
43
+ callback?: NotificationCallback;
44
+ }
45
+ export type NotificationCallback = (notification: Notification) => unknown;
46
+ type Reply = Record<string, any>;
47
+ /**
48
+ * Client for the local Droidline server. Emits server events by kind ("device",
49
+ * "notification", "screen", "toast", "result") and every event as "*". Only "result" arrives
50
+ * without subscribe(). Options fall back to DROIDLINE_HOST, DROIDLINE_PORT and DROIDLINE_TOKEN,
51
+ * then to 127.0.0.1:8780 without a token. The socket opens on the first call and reopens after a drop.
52
+ */
53
+ export declare class Droidline extends ClientCommands {
54
+ readonly host: string;
55
+ readonly port: number;
56
+ constructor(options?: ClientOptions);
57
+ /** True while the socket to the server is open. */
58
+ get connected(): boolean;
59
+ /** A handle for one phone. Without an argument it uses DROIDLINE_DEVICE, else the only online phone. */
60
+ device(idOrName?: string): Device;
61
+ /** Send any command, including ones newer than this SDK. Resolves with the reply without id and ok. */
62
+ call(cmd: string, params?: Params): Promise<Reply>;
63
+ /**
64
+ * Borrow a free phone so no other script can use it until release(). Optional: phones nobody
65
+ * leased keep working as before. Rejects with NO_FREE_DEVICE when nothing matched within `wait`.
66
+ */
67
+ lease(options?: Omit<LeaseOptions, keyof ClientOptions>): Promise<LeasedDevice>;
68
+ /**
69
+ * Give a leased phone back. Releasing twice is harmless. Pass `{ device }` instead of the
70
+ * lease to free a phone whose script crashed while holding it.
71
+ */
72
+ release(lease: string | {
73
+ device: string;
74
+ }): Promise<void>;
75
+ /** The last 200 requests on this connection, oldest first. */
76
+ get history(): HistoryEntry[];
77
+ /** Close the socket. Calls still waiting fail with CONNECTION_LOST; a later call reconnects. */
78
+ close(): void;
79
+ protected _run(cmd: string, kind: ReturnKind, params: Params): Promise<any>;
80
+ private deliver;
81
+ }
82
+ /** One phone. Every generated command method sends a request with this device's ID or name. */
83
+ export declare class Device extends DeviceCommands {
84
+ readonly client: Droidline;
85
+ readonly device: string | undefined;
86
+ constructor(client: Droidline, device?: string);
87
+ /** Set while this handle holds a lease; every request carries it. */
88
+ leaseId: string | undefined;
89
+ /** Send any command to this phone, including ones newer than this SDK. */
90
+ call(cmd: string, params?: Params): Promise<Reply>;
91
+ /** Recent requests to this phone, oldest first. */
92
+ get history(): HistoryEntry[];
93
+ /**
94
+ * Call `callback(notification)` for every matching notification from this phone. The phone
95
+ * forwards only the apps allowed with notify_filter(). Resolves with a function that stops the callback.
96
+ * @example const stop = await d.on_notification({ package: "com.kakao.talk" }, (n) => console.log(n.text))
97
+ */
98
+ on_notification(filter: NotificationFilter | NotificationCallback, callback?: NotificationCallback): Promise<() => void>;
99
+ /** @alias on_notification */
100
+ onNotification(filter: NotificationFilter | NotificationCallback, callback?: NotificationCallback): Promise<() => void>;
101
+ /** Close the connection if connect() opened it for this device; otherwise do nothing. */
102
+ close(): void;
103
+ protected _run(cmd: string, kind: ReturnKind, params: Params): Promise<any>;
104
+ protected _loadImage(image: unknown): Promise<string>;
105
+ protected _runElement(cmd: string, kind: ReturnKind, params: Params): Promise<Element | Element[]>;
106
+ protected _saveImage(cmd: string, params: Params): Promise<Buffer | string>;
107
+ protected _saveJson(cmd: string, params: Params): Promise<Reply>;
108
+ private identities;
109
+ }
110
+ /** A phone borrowed with lease(). Every request carries the lease; release() gives it back. */
111
+ export declare class LeasedDevice extends Device {
112
+ readonly name: string;
113
+ readonly ttl: number;
114
+ constructor(client: Droidline, device: string, leaseId: string, info: Reply);
115
+ /** Give the phone back. */
116
+ release(): Promise<void>;
117
+ /** Release the phone, then close the connection if lease() opened it. */
118
+ close(): void;
119
+ }
120
+ /**
121
+ * Like connect(), but borrows a free phone so no other script can use it.
122
+ * @example const d = await lease({ wait: 60 }); try { ... } finally { await d.release(); d.close(); }
123
+ */
124
+ export declare function lease(options?: LeaseOptions): Promise<LeasedDevice>;
125
+ /**
126
+ * Connect to the local Droidline server and resolve with a Device. Pass a device ID or name, or
127
+ * options. Rejects with SERVER_NOT_RUNNING right away if the server is not running.
128
+ */
129
+ export declare function connect(options?: string | ConnectOptions): Promise<Device>;
130
+ export {};
package/dist/client.js ADDED
@@ -0,0 +1,462 @@
1
+ import { readFile, writeFile } from "node:fs/promises";
2
+ import { createConnection } from "node:net";
3
+ import { extname } from "node:path";
4
+ import { DroidlineError, errorFromResponse } from "./errors.js";
5
+ import { Element } from "./element.js";
6
+ import { ClientCommands, DeviceCommands } from "./generated.js";
7
+ export const DEFAULT_HOST = "127.0.0.1";
8
+ export const DEFAULT_PORT = 8780;
9
+ const IMAGE_FORMATS = { ".png": "png", ".jpg": "jpeg", ".jpeg": "jpeg" };
10
+ const env = (name) => process.env[name] || undefined;
11
+ function lost() {
12
+ return new DroidlineError("The connection to the Droidline server dropped before the reply arrived. The command may or may not have run.", "CONNECTION_LOST");
13
+ }
14
+ function fields(reply) {
15
+ const { id: _id, ok: _ok, ...rest } = reply;
16
+ return rest;
17
+ }
18
+ function result(kind, reply) {
19
+ if (kind === "value")
20
+ return reply.value;
21
+ if (kind === "fields")
22
+ return fields(reply);
23
+ return undefined;
24
+ }
25
+ function message(cmd, device, params, lease) {
26
+ const msg = device === undefined ? { cmd, ...params } : { cmd, device, ...params };
27
+ if (lease !== undefined)
28
+ msg.lease = lease;
29
+ return msg;
30
+ }
31
+ const HISTORY_SIZE = 200;
32
+ /** Pipelines NDJSON requests over one socket and matches replies by id (PROTOCOL.md section 3). */
33
+ class Connection {
34
+ host;
35
+ port;
36
+ token;
37
+ onEvent;
38
+ keepAlive;
39
+ link;
40
+ opening;
41
+ nextId = 1;
42
+ subscriptions = [];
43
+ history = [];
44
+ constructor(host, port, token, onEvent, keepAlive) {
45
+ this.host = host;
46
+ this.port = port;
47
+ this.token = token;
48
+ this.onEvent = onEvent;
49
+ this.keepAlive = keepAlive;
50
+ }
51
+ get connected() {
52
+ return this.link?.alive === true;
53
+ }
54
+ async request(msg) {
55
+ const start = Date.now();
56
+ let reply;
57
+ try {
58
+ reply = await this.exchange(await this.ensure(), msg);
59
+ }
60
+ catch (err) {
61
+ this.note(msg, start, err instanceof DroidlineError ? err.code : "CONNECTION_LOST");
62
+ throw err;
63
+ }
64
+ this.note(msg, start, null);
65
+ if (msg.cmd === "subscribe") {
66
+ const key = JSON.stringify(msg);
67
+ if (!this.subscriptions.includes(key))
68
+ this.subscriptions.push(key);
69
+ }
70
+ else if (msg.cmd === "auth") {
71
+ this.token = msg.token;
72
+ }
73
+ return reply;
74
+ }
75
+ note(msg, start, error) {
76
+ if (msg.cmd === "auth")
77
+ return;
78
+ const params = {};
79
+ for (const [k, v] of Object.entries(msg)) {
80
+ if (["id", "cmd", "device", "lease", "token"].includes(k))
81
+ continue;
82
+ params[k] = typeof v === "string" && v.length > 200 ? `<${v.length} characters>` : v;
83
+ }
84
+ this.history.push({ time: start, device: msg.device, cmd: String(msg.cmd), params, ok: error === null, error, ms: Date.now() - start });
85
+ if (this.history.length > HISTORY_SIZE)
86
+ this.history.shift();
87
+ }
88
+ async subscribeOnce(msg) {
89
+ if (!this.subscriptions.includes(JSON.stringify(msg)))
90
+ await this.request(msg);
91
+ }
92
+ ensure() {
93
+ if (this.link?.alive)
94
+ return Promise.resolve(this.link);
95
+ this.opening ??= this.open().finally(() => {
96
+ this.opening = undefined;
97
+ });
98
+ return this.opening;
99
+ }
100
+ async open() {
101
+ const socket = await new Promise((resolve, reject) => {
102
+ const s = createConnection({ host: this.host, port: this.port });
103
+ const fail = (reason) => {
104
+ s.destroy();
105
+ reject(new DroidlineError(`Cannot reach the Droidline server at ${this.host}:${this.port} (${reason}). Start it with \`droidline serve\`.`, "SERVER_NOT_RUNNING"));
106
+ };
107
+ s.setTimeout(5000, () => fail("timed out"));
108
+ s.once("error", (err) => fail(err.code ?? err.message));
109
+ s.once("connect", () => {
110
+ s.setTimeout(0);
111
+ s.removeAllListeners("error");
112
+ resolve(s);
113
+ });
114
+ });
115
+ socket.setNoDelay(true);
116
+ socket.setEncoding("utf8");
117
+ const link = { socket, alive: true, pending: new Map() };
118
+ let buffer = "";
119
+ socket.on("data", (chunk) => {
120
+ buffer += chunk;
121
+ if (!chunk.includes("\n"))
122
+ return;
123
+ const lines = buffer.split("\n");
124
+ buffer = lines.pop() ?? "";
125
+ for (const line of lines)
126
+ this.dispatch(link, line);
127
+ });
128
+ // "close" always follows "error" and fails whatever is still pending.
129
+ socket.on("error", () => { });
130
+ socket.on("close", () => this.drop(link));
131
+ try {
132
+ // Auth must be the first line, and subscriptions belong to the socket, so a reconnect
133
+ // replays both before any other request goes out.
134
+ if (this.token)
135
+ await this.exchange(link, { cmd: "auth", token: this.token });
136
+ for (const sub of this.subscriptions)
137
+ await this.exchange(link, JSON.parse(sub));
138
+ }
139
+ catch (err) {
140
+ this.drop(link);
141
+ throw err;
142
+ }
143
+ this.link = link;
144
+ this.updateRef(link);
145
+ return link;
146
+ }
147
+ exchange(link, msg) {
148
+ if (!link.alive)
149
+ return Promise.reject(lost());
150
+ const id = this.nextId++;
151
+ return new Promise((resolve, reject) => {
152
+ link.pending.set(id, { resolve, reject });
153
+ this.updateRef(link);
154
+ link.socket.write(JSON.stringify({ id, ...msg }) + "\n");
155
+ });
156
+ }
157
+ dispatch(link, line) {
158
+ let parsed;
159
+ try {
160
+ parsed = JSON.parse(line);
161
+ }
162
+ catch {
163
+ return;
164
+ }
165
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
166
+ return;
167
+ const reply = parsed;
168
+ // Late results carry the request id too, so the event check comes first.
169
+ if ("event" in reply) {
170
+ this.onEvent(reply);
171
+ return;
172
+ }
173
+ const waiter = link.pending.get(reply.id);
174
+ if (!waiter)
175
+ return;
176
+ link.pending.delete(reply.id);
177
+ this.updateRef(link);
178
+ if (reply.ok === false)
179
+ waiter.reject(errorFromResponse(reply));
180
+ else
181
+ waiter.resolve(reply);
182
+ }
183
+ drop(link) {
184
+ if (!link.alive)
185
+ return;
186
+ link.alive = false;
187
+ if (this.link === link)
188
+ this.link = undefined;
189
+ link.socket.destroy();
190
+ const waiters = [...link.pending.values()];
191
+ link.pending.clear();
192
+ for (const w of waiters)
193
+ w.reject(lost());
194
+ }
195
+ /** An idle socket must not keep the process alive unless someone listens for events. */
196
+ updateRef(link = this.link) {
197
+ if (!link?.alive)
198
+ return;
199
+ if (link.pending.size > 0 || this.keepAlive())
200
+ link.socket.ref();
201
+ else
202
+ link.socket.unref();
203
+ }
204
+ close() {
205
+ if (this.link)
206
+ this.drop(this.link);
207
+ }
208
+ }
209
+ const connections = new WeakMap();
210
+ const ownedDevices = new WeakSet();
211
+ function conn(client) {
212
+ return connections.get(client);
213
+ }
214
+ /**
215
+ * Client for the local Droidline server. Emits server events by kind ("device",
216
+ * "notification", "screen", "toast", "result") and every event as "*". Only "result" arrives
217
+ * without subscribe(). Options fall back to DROIDLINE_HOST, DROIDLINE_PORT and DROIDLINE_TOKEN,
218
+ * then to 127.0.0.1:8780 without a token. The socket opens on the first call and reopens after a drop.
219
+ */
220
+ export class Droidline extends ClientCommands {
221
+ host;
222
+ port;
223
+ constructor(options = {}) {
224
+ super();
225
+ this.host = options.host ?? env("DROIDLINE_HOST") ?? DEFAULT_HOST;
226
+ this.port = Number(options.port ?? env("DROIDLINE_PORT") ?? DEFAULT_PORT);
227
+ const connection = new Connection(this.host, this.port, options.token ?? env("DROIDLINE_TOKEN"), (event) => this.deliver(event), () => this.eventNames().some((name) => name !== "newListener" && name !== "removeListener"));
228
+ connections.set(this, connection);
229
+ const refresh = () => queueMicrotask(() => connection.updateRef());
230
+ this.on("newListener", refresh);
231
+ this.on("removeListener", refresh);
232
+ }
233
+ /** True while the socket to the server is open. */
234
+ get connected() {
235
+ return conn(this).connected;
236
+ }
237
+ /** A handle for one phone. Without an argument it uses DROIDLINE_DEVICE, else the only online phone. */
238
+ device(idOrName) {
239
+ return new Device(this, idOrName ?? env("DROIDLINE_DEVICE"));
240
+ }
241
+ /** Send any command, including ones newer than this SDK. Resolves with the reply without id and ok. */
242
+ async call(cmd, params = {}) {
243
+ return fields(await conn(this).request({ cmd, ...params }));
244
+ }
245
+ /**
246
+ * Borrow a free phone so no other script can use it until release(). Optional: phones nobody
247
+ * leased keep working as before. Rejects with NO_FREE_DEVICE when nothing matched within `wait`.
248
+ */
249
+ async lease(options = {}) {
250
+ const params = {};
251
+ for (const [k, v] of Object.entries(options))
252
+ if (v !== undefined)
253
+ params[k] = v;
254
+ const reply = fields(await conn(this).request(message("lease", undefined, params)));
255
+ return new LeasedDevice(this, String(reply.device), String(reply.lease), reply);
256
+ }
257
+ /**
258
+ * Give a leased phone back. Releasing twice is harmless. Pass `{ device }` instead of the
259
+ * lease to free a phone whose script crashed while holding it.
260
+ */
261
+ async release(lease) {
262
+ await conn(this).request(typeof lease === "string" ? { cmd: "release", lease } : { cmd: "release", device: lease.device });
263
+ }
264
+ /** The last 200 requests on this connection, oldest first. */
265
+ get history() {
266
+ return [...conn(this).history];
267
+ }
268
+ /** Close the socket. Calls still waiting fail with CONNECTION_LOST; a later call reconnects. */
269
+ close() {
270
+ conn(this).close();
271
+ }
272
+ async _run(cmd, kind, params) {
273
+ return result(kind, await conn(this).request(message(cmd, undefined, params)));
274
+ }
275
+ deliver(event) {
276
+ for (const name of [String(event.event), "*"]) {
277
+ // EventEmitter throws on an "error" event nobody listens to; servers never send one.
278
+ if (name === "error")
279
+ continue;
280
+ try {
281
+ this.emit(name, event);
282
+ }
283
+ catch (err) {
284
+ console.error(err);
285
+ }
286
+ }
287
+ }
288
+ }
289
+ /** One phone. Every generated command method sends a request with this device's ID or name. */
290
+ export class Device extends DeviceCommands {
291
+ client;
292
+ device;
293
+ constructor(client, device) {
294
+ super();
295
+ this.client = client;
296
+ this.device = device;
297
+ }
298
+ /** Set while this handle holds a lease; every request carries it. */
299
+ leaseId;
300
+ /** Send any command to this phone, including ones newer than this SDK. */
301
+ async call(cmd, params = {}) {
302
+ return fields(await conn(this.client).request(message(cmd, this.device, params, this.leaseId)));
303
+ }
304
+ /** Recent requests to this phone, oldest first. */
305
+ get history() {
306
+ return this.client.history.filter((e) => this.device === undefined || e.device === this.device);
307
+ }
308
+ /**
309
+ * Call `callback(notification)` for every matching notification from this phone. The phone
310
+ * forwards only the apps allowed with notify_filter(). Resolves with a function that stops the callback.
311
+ * @example const stop = await d.on_notification({ package: "com.kakao.talk" }, (n) => console.log(n.text))
312
+ */
313
+ async on_notification(filter, callback) {
314
+ const opts = typeof filter === "function" ? {} : filter;
315
+ const cb = typeof filter === "function" ? filter : (callback ?? opts.callback);
316
+ if (typeof cb !== "function")
317
+ throw new TypeError("on_notification() needs a callback");
318
+ const ids = await this.identities();
319
+ const text = opts.textContains;
320
+ const handler = (event) => {
321
+ if (ids.size > 0 && !ids.has(event.device) && !ids.has(event.name))
322
+ return;
323
+ if (opts.package !== undefined && event.package !== opts.package)
324
+ return;
325
+ if (text !== undefined && ![event.title, event.text].some((v) => String(v ?? "").includes(text)))
326
+ return;
327
+ cb(event);
328
+ };
329
+ this.client.on("notification", handler);
330
+ try {
331
+ await conn(this.client).subscribeOnce({ cmd: "subscribe", events: ["notification"] });
332
+ }
333
+ catch (err) {
334
+ this.client.off("notification", handler);
335
+ throw err;
336
+ }
337
+ return () => {
338
+ this.client.off("notification", handler);
339
+ };
340
+ }
341
+ /** @alias on_notification */
342
+ onNotification(filter, callback) {
343
+ return this.on_notification(filter, callback);
344
+ }
345
+ /** Close the connection if connect() opened it for this device; otherwise do nothing. */
346
+ close() {
347
+ if (ownedDevices.has(this))
348
+ this.client.close();
349
+ }
350
+ async _run(cmd, kind, params) {
351
+ return result(kind, await conn(this.client).request(message(cmd, this.device, params, this.leaseId)));
352
+ }
353
+ async _loadImage(image) {
354
+ // A file path or the bytes of a PNG or JPEG; the wire carries base64.
355
+ if (image instanceof Uint8Array)
356
+ return Buffer.from(image).toString("base64");
357
+ return (await readFile(String(image))).toString("base64");
358
+ }
359
+ async _runElement(cmd, kind, params) {
360
+ const found = await this._run(cmd, kind, params);
361
+ return Array.isArray(found) ? found.map((n) => new Element(this, n)) : new Element(this, found);
362
+ }
363
+ async _saveImage(cmd, params) {
364
+ const { path, ...rest } = params;
365
+ if (typeof path === "string" && rest.format === undefined) {
366
+ const format = IMAGE_FORMATS[extname(path).toLowerCase()];
367
+ if (format)
368
+ rest.format = format;
369
+ }
370
+ const reply = (await this._run(cmd, "fields", rest));
371
+ const image = Buffer.from(String(reply.data ?? ""), "base64");
372
+ if (typeof path !== "string")
373
+ return image;
374
+ await writeFile(path, image);
375
+ return path;
376
+ }
377
+ async _saveJson(cmd, params) {
378
+ const { path, ...rest } = params;
379
+ const reply = (await this._run(cmd, "fields", rest));
380
+ if (typeof path === "string")
381
+ await writeFile(path, JSON.stringify(reply, null, 2), "utf8");
382
+ return reply;
383
+ }
384
+ // Events name the device by ID; resolve a name so both match. Empty means any device.
385
+ async identities() {
386
+ if (this.device === undefined)
387
+ return new Set();
388
+ const ids = new Set([this.device]);
389
+ try {
390
+ const list = await this.client.devices();
391
+ for (const info of list) {
392
+ if (info.id === this.device || info.name === this.device) {
393
+ if (info.id)
394
+ ids.add(info.id);
395
+ if (info.name)
396
+ ids.add(info.name);
397
+ }
398
+ }
399
+ }
400
+ catch (err) {
401
+ if (!(err instanceof DroidlineError))
402
+ throw err;
403
+ }
404
+ return ids;
405
+ }
406
+ }
407
+ /** A phone borrowed with lease(). Every request carries the lease; release() gives it back. */
408
+ export class LeasedDevice extends Device {
409
+ name;
410
+ ttl;
411
+ constructor(client, device, leaseId, info) {
412
+ super(client, device);
413
+ this.leaseId = leaseId;
414
+ this.name = String(info.name ?? device);
415
+ this.ttl = Number(info.ttl ?? 0);
416
+ }
417
+ /** Give the phone back. */
418
+ async release() {
419
+ const id = this.leaseId;
420
+ this.leaseId = undefined;
421
+ if (id !== undefined)
422
+ await this.client.release(id);
423
+ }
424
+ /** Release the phone, then close the connection if lease() opened it. */
425
+ close() {
426
+ const id = this.leaseId;
427
+ this.leaseId = undefined;
428
+ const done = () => super.close();
429
+ if (id === undefined)
430
+ return done();
431
+ this.client.release(id).catch(() => undefined).finally(done);
432
+ }
433
+ }
434
+ /**
435
+ * Like connect(), but borrows a free phone so no other script can use it.
436
+ * @example const d = await lease({ wait: 60 }); try { ... } finally { await d.release(); d.close(); }
437
+ */
438
+ export async function lease(options = {}) {
439
+ const { host, port, token, ...rest } = options;
440
+ const client = new Droidline({ host, port, token });
441
+ try {
442
+ const d = await client.lease(rest);
443
+ ownedDevices.add(d);
444
+ return d;
445
+ }
446
+ catch (err) {
447
+ client.close();
448
+ throw err;
449
+ }
450
+ }
451
+ /**
452
+ * Connect to the local Droidline server and resolve with a Device. Pass a device ID or name, or
453
+ * options. Rejects with SERVER_NOT_RUNNING right away if the server is not running.
454
+ */
455
+ export async function connect(options = {}) {
456
+ const opts = typeof options === "string" ? { device: options } : options;
457
+ const client = new Droidline(opts);
458
+ await conn(client).ensure();
459
+ const d = client.device(opts.device);
460
+ ownedDevices.add(d);
461
+ return d;
462
+ }