@sandboxrouter/sdk 0.2.0 → 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.
Files changed (91) hide show
  1. package/README.md +224 -48
  2. package/dist/bench.js +4 -4
  3. package/dist/catalog.d.ts +159 -0
  4. package/dist/catalog.js +448 -15
  5. package/dist/check.d.ts +28 -0
  6. package/dist/check.js +185 -0
  7. package/dist/cli.js +148 -11
  8. package/dist/computers.d.ts +55 -13
  9. package/dist/computers.js +132 -6
  10. package/dist/conformance.d.ts +46 -0
  11. package/dist/conformance.js +65 -0
  12. package/dist/cost.d.ts +67 -0
  13. package/dist/cost.js +153 -0
  14. package/dist/gc.d.ts +29 -0
  15. package/dist/gc.js +54 -0
  16. package/dist/index.d.ts +28 -2
  17. package/dist/index.js +28 -2
  18. package/dist/leaderboard.d.ts +6 -0
  19. package/dist/leaderboard.js +5 -4
  20. package/dist/ledger.d.ts +38 -0
  21. package/dist/ledger.js +89 -0
  22. package/dist/providers/agent37.d.ts +40 -0
  23. package/dist/providers/agent37.js +224 -0
  24. package/dist/providers/agentcore.d.ts +71 -0
  25. package/dist/providers/agentcore.js +446 -0
  26. package/dist/providers/alibaba.d.ts +18 -0
  27. package/dist/providers/alibaba.js +106 -0
  28. package/dist/providers/azure.d.ts +43 -0
  29. package/dist/providers/azure.js +330 -0
  30. package/dist/providers/beam.d.ts +11 -0
  31. package/dist/providers/beam.js +179 -0
  32. package/dist/providers/blaxel.d.ts +3 -1
  33. package/dist/providers/blaxel.js +32 -22
  34. package/dist/providers/boat.js +8 -3
  35. package/dist/providers/cloudflare.js +8 -3
  36. package/dist/providers/codesandbox.d.ts +59 -0
  37. package/dist/providers/codesandbox.js +287 -0
  38. package/dist/providers/daytona.d.ts +25 -4
  39. package/dist/providers/daytona.js +449 -28
  40. package/dist/providers/dedalus.d.ts +12 -0
  41. package/dist/providers/dedalus.js +244 -0
  42. package/dist/providers/deno.d.ts +50 -0
  43. package/dist/providers/deno.js +170 -0
  44. package/dist/providers/docker.js +11 -5
  45. package/dist/providers/e2b.d.ts +70 -3
  46. package/dist/providers/e2b.js +376 -8
  47. package/dist/providers/exedev.js +17 -5
  48. package/dist/providers/freestyle.d.ts +49 -0
  49. package/dist/providers/freestyle.js +261 -0
  50. package/dist/providers/hopx.js +27 -18
  51. package/dist/providers/instabox.d.ts +76 -0
  52. package/dist/providers/instabox.js +360 -0
  53. package/dist/providers/koyeb.d.ts +71 -0
  54. package/dist/providers/koyeb.js +379 -0
  55. package/dist/providers/lambda.d.ts +51 -0
  56. package/dist/providers/lambda.js +467 -0
  57. package/dist/providers/langsmith.d.ts +16 -0
  58. package/dist/providers/langsmith.js +149 -0
  59. package/dist/providers/limrun.d.ts +77 -0
  60. package/dist/providers/limrun.js +626 -0
  61. package/dist/providers/maritime.d.ts +5 -1
  62. package/dist/providers/maritime.js +39 -8
  63. package/dist/providers/mobilerun.d.ts +44 -0
  64. package/dist/providers/mobilerun.js +417 -0
  65. package/dist/providers/modal.js +12 -4
  66. package/dist/providers/morph.js +12 -4
  67. package/dist/providers/northflank.js +11 -13
  68. package/dist/providers/novita.d.ts +12 -0
  69. package/dist/providers/novita.js +81 -0
  70. package/dist/providers/openscale.d.ts +17 -0
  71. package/dist/providers/openscale.js +87 -0
  72. package/dist/providers/orgo.d.ts +45 -0
  73. package/dist/providers/orgo.js +414 -0
  74. package/dist/providers/sail.d.ts +32 -0
  75. package/dist/providers/sail.js +372 -0
  76. package/dist/providers/superserve.d.ts +25 -0
  77. package/dist/providers/superserve.js +337 -0
  78. package/dist/providers/tensorlake.js +17 -14
  79. package/dist/providers/upstash.d.ts +30 -0
  80. package/dist/providers/upstash.js +258 -0
  81. package/dist/providers/vercel.js +14 -3
  82. package/dist/providers/workspaces.d.ts +60 -0
  83. package/dist/providers/workspaces.js +662 -0
  84. package/dist/router.d.ts +36 -5
  85. package/dist/router.js +149 -19
  86. package/dist/server.d.ts +1 -1
  87. package/dist/server.js +22 -3
  88. package/dist/types.d.ts +13 -5
  89. package/dist/util.d.ts +28 -1
  90. package/dist/util.js +56 -7
  91. package/package.json +80 -5
@@ -0,0 +1,626 @@
1
+ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { ProviderError } from "../types.js";
5
+ import { optionalImport, toBytes } from "../util.js";
6
+ /**
7
+ * Limrun: cloud Android emulators and iOS simulators, driven through `@limrun/api`.
8
+ * Control plane: `new Limrun({ apiKey })` -> androidInstances / iosInstances (create, get, delete).
9
+ * Device: createInstanceClient (Android) / Ios.createInstanceClient, built from the instance's
10
+ * status.apiUrl + status.token (a WebSocket; no API key needed).
11
+ * Docs: https://docs.limrun.com/docs/reference/sdk.md
12
+ *
13
+ * Canonical action mapping (coordinates are in the last screenshot's frame; iOS screenshots are
14
+ * the SDK's default "scaled" size, i.e. tap points, so they line up with taps):
15
+ *
16
+ * screenshot Android client.screenshot() (data URI) | iOS screenshot({ format: "png" })
17
+ * left_click tap(x, y); with `duration` (s) > 0 it is a long press of that length
18
+ * right_click long press (800 ms), the phone's context-menu gesture
19
+ * double/triple_click 2 or 3 quick taps (Android: one `input tap` shell line; iOS: one performActions batch)
20
+ * left_click_drag swipe startCoordinate -> coordinate (Android `input swipe`; iOS touchDown/touchMove/touchUp)
21
+ * scroll Android scrollScreen / scrollElement({x, y}) with amount = steps;
22
+ * iOS scroll(direction, steps x 100 points, { coordinate })
23
+ * type Android setText(focused input) | iOS typeText(requireFocus: false)
24
+ * key xdotool-style chords, see KEYS below; `repeat` presses it again
25
+ * wait sleep `duration` seconds (default 1)
26
+ * cursor_position, mouse_move, middle_click, left_mouse_down/up, hold_key, zoom
27
+ * error result: phones have no cursor, hover or held keys, and there is no
28
+ * in-process image crop
29
+ *
30
+ * Key table (case-insensitive xdotool names -> Android pressKey | iOS):
31
+ * Return, Enter, KP_Enter ENTER | pressKey("enter")
32
+ * BackSpace DEL | pressKey("backspace")
33
+ * Delete FORWARD_DEL | pressKey("delete")
34
+ * Tab TAB | pressKey("tab")
35
+ * Escape, Esc BACK | pressKey("escape")
36
+ * Back BACK | error (no Back button on iOS)
37
+ * Home, Super, XF86HomePage HOME | home button
38
+ * Up/Down/Left/Right DPAD_* | pressKey("up" ...)
39
+ * space SPACE | pressKey("space")
40
+ * XF86AudioRaiseVolume VOLUME_UP | volumeUp button
41
+ * XF86AudioLowerVolume VOLUME_DOWN | volumeDown button
42
+ * XF86AudioMute VOLUME_MUTE | error
43
+ * Power, XF86PowerOff, Lock POWER | lock button
44
+ * Menu MENU | error
45
+ * a single digit KEYCODE_<d> | the digit (Android reads a bare "4" as keycode 4 = BACK)
46
+ * anything else passed through | passed through, lowercased
47
+ * Modifiers in a chord (ctrl+a): ctrl/control, shift, alt/option, super/meta/cmd/command.
48
+ */
49
+ export class LimrunProvider {
50
+ apiKey;
51
+ opts;
52
+ id = "limrun";
53
+ /** Android emulators and iPhone simulators. Limrun also offers tablets/iPads, not routed here. */
54
+ computerOs = ["android", "ios"];
55
+ sdk;
56
+ constructor(apiKey = process.env.LIM_API_KEY, opts = {}) {
57
+ this.apiKey = apiKey;
58
+ this.opts = opts;
59
+ }
60
+ configured() {
61
+ return !!this.apiKey;
62
+ }
63
+ /** @internal */
64
+ client() {
65
+ if (!this.apiKey)
66
+ return Promise.reject(err("LIM_API_KEY is not set"));
67
+ this.sdk ??= optionalImport("@limrun/api", "limrun").then((mod) => ({
68
+ mod,
69
+ lim: new mod.Limrun({ apiKey: this.apiKey, ...(this.opts.baseURL ? { baseURL: this.opts.baseURL } : {}) }),
70
+ }));
71
+ return this.sdk;
72
+ }
73
+ async createComputer(spec) {
74
+ const os = spec.os ?? "android";
75
+ if (os !== "android" && os !== "ios")
76
+ throw err(`no ${os} computers (limrun offers android and ios)`);
77
+ const { mod, lim } = await this.client();
78
+ const labels = spec.externalUserId ? { externalUserId: spec.externalUserId } : undefined;
79
+ const metadata = { ...(spec.name ? { displayName: spec.name } : {}), ...(labels ? { labels } : {}) };
80
+ const common = {
81
+ ...(this.opts.inactivityTimeout ? { inactivityTimeout: this.opts.inactivityTimeout } : {}),
82
+ ...(this.opts.region ? { region: this.opts.region } : {}),
83
+ };
84
+ // reuseIfExists returns the instance that already carries these labels: same user, same device.
85
+ const params = { wait: true, reuseIfExists: !!labels, metadata };
86
+ try {
87
+ const inst = os === "android"
88
+ ? await lim.androidInstances.create({ ...params, spec: { ...common, model: "phone" } })
89
+ : await lim.iosInstances.create({ ...params, spec: { ...common, model: "iphone" } });
90
+ const c = new LimrunComputer(mod, lim, os, inst);
91
+ await c.waitReady();
92
+ return c;
93
+ }
94
+ catch (e) {
95
+ throw wrap(e);
96
+ }
97
+ }
98
+ /** Limrun ids don't say which platform they belong to, so try Android, then iOS. */
99
+ async connectComputer(nativeId) {
100
+ const { mod, lim } = await this.client();
101
+ try {
102
+ return new LimrunComputer(mod, lim, "android", await lim.androidInstances.get(nativeId));
103
+ }
104
+ catch (e) {
105
+ if (!isNotFound(e))
106
+ throw wrap(e);
107
+ }
108
+ try {
109
+ return new LimrunComputer(mod, lim, "ios", await lim.iosInstances.get(nativeId));
110
+ }
111
+ catch (e) {
112
+ throw isNotFound(e) ? err(`no android or ios instance ${nativeId}`, 404) : wrap(e);
113
+ }
114
+ }
115
+ }
116
+ const READY_TIMEOUT_MS = 5 * 60_000;
117
+ /** iOS scroll takes pixels (points); one canonical scroll step moves this far. */
118
+ const IOS_POINTS_PER_STEP = 100;
119
+ const LONG_PRESS_MS = 800;
120
+ const SWIPE_MS = 300;
121
+ class LimrunComputer {
122
+ mod;
123
+ lim;
124
+ os;
125
+ inst;
126
+ nativeId;
127
+ dev;
128
+ frame = 0;
129
+ size;
130
+ constructor(mod, lim, os, inst) {
131
+ this.mod = mod;
132
+ this.lim = lim;
133
+ this.os = os;
134
+ this.inst = inst;
135
+ this.nativeId = inst.metadata.id;
136
+ }
137
+ api() {
138
+ return this.os === "android" ? this.lim.androidInstances : this.lim.iosInstances;
139
+ }
140
+ async refresh() {
141
+ this.inst = await this.api().get(this.nativeId);
142
+ return this.inst;
143
+ }
144
+ /** @internal create({ wait: true }) normally returns ready; poll in case it came back early. */
145
+ async waitReady() {
146
+ const t0 = Date.now();
147
+ while (this.inst.status.state !== "ready") {
148
+ if (this.inst.status.state === "terminated") {
149
+ throw err(`instance ${this.nativeId} terminated before it was ready${this.inst.status.errorMessage ? `: ${this.inst.status.errorMessage}` : ""}`);
150
+ }
151
+ if (Date.now() - t0 > READY_TIMEOUT_MS)
152
+ throw err(`instance ${this.nativeId} not ready after ${READY_TIMEOUT_MS / 1000}s (state: ${this.inst.status.state})`);
153
+ await sleep(2000);
154
+ await this.refresh();
155
+ }
156
+ }
157
+ /** The device WebSocket client, opened once and reopened if it dropped for good. */
158
+ device() {
159
+ const cur = this.dev;
160
+ if (!cur)
161
+ return this.reopen();
162
+ return cur.then((d) => (d.c.getConnectionState() === "disconnected" ? this.reopen(cur) : d));
163
+ }
164
+ reopen(stale) {
165
+ if (stale && this.dev && this.dev !== stale)
166
+ return this.dev; // another call already reopened
167
+ const p = this.open();
168
+ this.dev = p;
169
+ p.catch(() => {
170
+ if (this.dev === p)
171
+ this.dev = undefined;
172
+ });
173
+ return p;
174
+ }
175
+ async open() {
176
+ if (this.inst.status.state !== "ready" || !this.inst.status.apiUrl || !this.inst.status.token)
177
+ await this.refresh();
178
+ const s = this.inst.status;
179
+ if (s.state !== "ready")
180
+ throw err(`instance ${this.nativeId} is ${s.state}`);
181
+ if (!s.apiUrl || !s.token)
182
+ throw err(`instance ${this.nativeId} came back without apiUrl/token (a read-only key can't control devices)`);
183
+ if (this.os === "android") {
184
+ const adbUrl = s.adbWebSocketUrl;
185
+ return { os: "android", c: await this.mod.createInstanceClient({ apiUrl: s.apiUrl, token: s.token, ...(adbUrl ? { adbUrl } : {}), logLevel: "error" }) };
186
+ }
187
+ return { os: "ios", c: await this.mod.Ios.createInstanceClient({ apiUrl: s.apiUrl, token: s.token, logLevel: "error" }) };
188
+ }
189
+ async info() {
190
+ const inst = await this.refresh().catch((e) => {
191
+ throw wrap(e);
192
+ });
193
+ const state = inst.status.state;
194
+ const info = {
195
+ id: this.nativeId,
196
+ os: this.os,
197
+ status: state === "creating" || state === "assigned" ? "creating" : state,
198
+ externalUserId: inst.metadata.labels?.externalUserId ?? null,
199
+ };
200
+ if (state === "ready") {
201
+ const screen = await this.screenSize().catch(() => undefined);
202
+ if (screen)
203
+ info.screen = screen;
204
+ }
205
+ return info;
206
+ }
207
+ async screenSize() {
208
+ const d = await this.device();
209
+ if (d.os === "ios")
210
+ return { width: d.c.deviceInfo.screenWidth, height: d.c.deviceInfo.screenHeight };
211
+ if (!this.size)
212
+ await this.shot();
213
+ return this.size;
214
+ }
215
+ async act(a) {
216
+ try {
217
+ const extra = await this.perform(a);
218
+ if (a.noScreenshot)
219
+ return { action: a.action, ok: true, ...extra };
220
+ return { action: a.action, ok: true, ...extra, ...(await this.frameResult()) };
221
+ }
222
+ catch (e) {
223
+ return { action: a.action, ok: false, error: e?.message ?? String(e) };
224
+ }
225
+ }
226
+ /** Runs in order and stops at the first error; only the last action returns a screenshot. */
227
+ async actBatch(actions) {
228
+ const out = [];
229
+ for (const [i, a] of actions.entries()) {
230
+ const r = await this.act(i === actions.length - 1 ? a : { ...a, noScreenshot: true });
231
+ out.push(r);
232
+ if (!r.ok)
233
+ break;
234
+ }
235
+ return out;
236
+ }
237
+ async frameResult() {
238
+ const s = await this.shot();
239
+ return { frameId: ++this.frame, imageB64: s.b64, mime: s.mime, width: s.width, height: s.height };
240
+ }
241
+ async shot(format = "png") {
242
+ const d = await this.device();
243
+ let s;
244
+ if (d.os === "ios") {
245
+ const r = await d.c.screenshot({ format });
246
+ s = { b64: r.base64, mime: `image/${format}`, width: r.width, height: r.height };
247
+ }
248
+ else {
249
+ const { dataUri } = await d.c.screenshot();
250
+ const m = /^data:([^;,]+)?(;base64)?,(.*)$/s.exec(dataUri ?? "");
251
+ if (!m || !m[3])
252
+ throw new Error("screenshot came back empty");
253
+ const b64 = m[2] ? m[3] : Buffer.from(decodeURIComponent(m[3]), "binary").toString("base64");
254
+ s = { b64, mime: m[1] ?? "image/png", ...imageSize(b64) };
255
+ }
256
+ if (s.width && s.height)
257
+ this.size = { width: s.width, height: s.height };
258
+ return s;
259
+ }
260
+ /** One canonical action to SDK calls. Returns extra result fields. */
261
+ async perform(a) {
262
+ const d = await this.device();
263
+ const xy = () => {
264
+ if (!a.coordinate)
265
+ throw new Error(`${a.action} needs coordinate (phones have no cursor)`);
266
+ return a.coordinate.map(Math.round);
267
+ };
268
+ const noMods = () => {
269
+ if (a.modifier)
270
+ throw new Error(`${a.action} with a modifier isn't supported on a touchscreen`);
271
+ };
272
+ switch (a.action) {
273
+ case "screenshot":
274
+ return {};
275
+ case "left_click": {
276
+ noMods();
277
+ const [x, y] = xy();
278
+ if (a.duration && a.duration > 0)
279
+ await longPress(d, x, y, seconds(a.duration) * 1000);
280
+ else if (d.os === "android")
281
+ await d.c.tap({ x, y });
282
+ else
283
+ await d.c.tap(x, y);
284
+ return {};
285
+ }
286
+ case "right_click": {
287
+ noMods();
288
+ const [x, y] = xy();
289
+ await longPress(d, x, y, LONG_PRESS_MS);
290
+ return {};
291
+ }
292
+ case "double_click":
293
+ case "triple_click": {
294
+ noMods();
295
+ const [x, y] = xy();
296
+ await taps(d, x, y, a.action === "double_click" ? 2 : 3);
297
+ return {};
298
+ }
299
+ case "left_click_drag": {
300
+ if (!a.startCoordinate)
301
+ throw new Error("left_click_drag needs startCoordinate (phones have no cursor)");
302
+ const [x2, y2] = xy();
303
+ const [x1, y1] = a.startCoordinate.map(Math.round);
304
+ await swipe(d, x1, y1, x2, y2, a.duration && a.duration > 0 ? seconds(a.duration) * 1000 : SWIPE_MS);
305
+ return {};
306
+ }
307
+ case "scroll": {
308
+ if (!a.scrollDirection)
309
+ throw new Error("scroll needs scrollDirection");
310
+ const steps = Math.max(1, Math.round(a.scrollAmount ?? 3));
311
+ const at = a.coordinate?.map(Math.round);
312
+ if (d.os === "android") {
313
+ if (at)
314
+ await d.c.scrollElement({ x: at[0], y: at[1] }, a.scrollDirection, steps);
315
+ else
316
+ await d.c.scrollScreen(a.scrollDirection, steps);
317
+ }
318
+ else {
319
+ await d.c.scroll(a.scrollDirection, steps * IOS_POINTS_PER_STEP, at ? { coordinate: at } : undefined);
320
+ }
321
+ return {};
322
+ }
323
+ case "type":
324
+ if (!a.text)
325
+ throw new Error("type needs text");
326
+ if (d.os === "android")
327
+ await d.c.setText(undefined, a.text);
328
+ else
329
+ await d.c.typeText(a.text, false, { requireFocus: false });
330
+ return {};
331
+ case "key": {
332
+ if (!a.text)
333
+ throw new Error("key needs text");
334
+ // xdotool accepts several space-separated chords: "ctrl+a BackSpace".
335
+ const chords = a.text.trim().split(/\s+/).map((c) => limrunKey(c, d.os));
336
+ for (let n = 0; n < Math.max(1, a.repeat ?? 1); n++) {
337
+ for (const k of chords) {
338
+ if (d.os === "android")
339
+ await d.c.pressKey(k.key, k.modifiers.length ? k.modifiers : undefined);
340
+ else if (k.button)
341
+ await d.c.performActions([{ type: "buttonDown", button: k.button }, { type: "buttonUp", button: k.button }]);
342
+ else
343
+ await d.c.pressKey(k.key, k.modifiers.length ? k.modifiers : undefined);
344
+ }
345
+ }
346
+ return {};
347
+ }
348
+ case "wait":
349
+ await sleep(seconds(a.duration ?? 1) * 1000);
350
+ return {};
351
+ case "cursor_position":
352
+ throw new Error("cursor_position: phones have no cursor");
353
+ case "mouse_move":
354
+ throw new Error("mouse_move: a touchscreen has no hover; tap or swipe instead");
355
+ case "zoom":
356
+ throw new Error("zoom isn't supported on limrun phones; use a full screenshot");
357
+ case "left_mouse_down":
358
+ case "left_mouse_up":
359
+ throw new Error(`${a.action} isn't supported on limrun phones; use left_click_drag or left_click with duration`);
360
+ default:
361
+ throw new Error(`limrun does not support action "${a.action}" on ${this.os}`);
362
+ }
363
+ }
364
+ async screenshot(opts = {}) {
365
+ try {
366
+ const s = await this.shot(opts.format ?? "png");
367
+ return new Uint8Array(Buffer.from(s.b64, "base64"));
368
+ }
369
+ catch (e) {
370
+ throw wrap(e);
371
+ }
372
+ }
373
+ /** Android: `adb shell` as the shell user. iOS simulators have no shell. */
374
+ async exec(command, timeoutSeconds = 30) {
375
+ if (this.os === "ios")
376
+ throw err("iOS simulators have no shell (Limrun exposes only simctl, a few xcrun flags and file transfer)");
377
+ const d = (await this.device());
378
+ try {
379
+ const r = await d.c.adbShell("sh", ["-c", command], { timeoutMs: Math.max(1, timeoutSeconds) * 1000 });
380
+ return { exitCode: r.exitCode, stdout: r.stdout, stderr: r.truncated ? `${r.stderr}\n[limrun: output truncated]` : r.stderr };
381
+ }
382
+ catch (e) {
383
+ throw wrap(e);
384
+ }
385
+ }
386
+ /**
387
+ * Android: like `adb pull` (readable paths such as /sdcard, /data/local/tmp).
388
+ * iOS: a file in the simulator's staging folder (the path is relative to it; a leading / is dropped).
389
+ */
390
+ async readFile(path) {
391
+ const d = await this.device();
392
+ try {
393
+ if (d.os === "ios")
394
+ return new Uint8Array(await d.c.pullFile(path.replace(/^\/+/, "")));
395
+ return await withTemp(async (local) => {
396
+ await d.c.pullFile(path, local);
397
+ return new Uint8Array(await readFile(local));
398
+ });
399
+ }
400
+ catch (e) {
401
+ throw wrap(e);
402
+ }
403
+ }
404
+ /** Android: like `adb push`. iOS: into the simulator's staging folder (path relative to it). */
405
+ async writeFile(path, content) {
406
+ const d = await this.device();
407
+ try {
408
+ await withTemp(async (local) => {
409
+ await writeFile(local, toBytes(content));
410
+ if (d.os === "ios")
411
+ await d.c.pushFile(local, path.replace(/^\/+/, ""));
412
+ else
413
+ await d.c.pushFile(local, path);
414
+ });
415
+ }
416
+ catch (e) {
417
+ throw wrap(e);
418
+ }
419
+ }
420
+ /**
421
+ * status.signedStreamUrl: the token is in the URL, no sign-in needed, and it opens an interactive
422
+ * stream (Limrun: "watch and control"). There is no view-only variant, so `watch` is refused
423
+ * rather than handing out control. The SDK gives no expiry; it lives with the instance.
424
+ */
425
+ async viewer(mode = "watch", _opts = {}) {
426
+ if (mode === "watch")
427
+ throw err("limrun has no view-only link: the signed stream URL also controls the device; ask for mode \"control\"");
428
+ const inst = await this.refresh().catch((e) => {
429
+ throw wrap(e);
430
+ });
431
+ const url = inst.status.signedStreamUrl;
432
+ if (!url)
433
+ throw err(`instance ${this.nativeId} has no stream URL (state: ${inst.status.state}; a read-only key gets none)`);
434
+ return { url, expiresAt: null };
435
+ }
436
+ // No sleep(): Limrun documents no pause; an idle device is deleted after its inactivity timeout.
437
+ async delete() {
438
+ const dev = this.dev;
439
+ this.dev = undefined;
440
+ if (dev)
441
+ await dev.then((d) => d.c.disconnect()).catch(() => { });
442
+ try {
443
+ await this.api().delete(this.nativeId);
444
+ }
445
+ catch (e) {
446
+ if (!isNotFound(e))
447
+ throw wrap(e);
448
+ }
449
+ }
450
+ }
451
+ async function taps(d, x, y, n) {
452
+ if (d.os === "android") {
453
+ // One shell line so the taps land close enough together to count as a double/triple tap.
454
+ const r = await d.c.adbShell("sh", ["-c", Array(n).fill(`input tap ${x} ${y}`).join("; ")]);
455
+ if (r.exitCode !== 0)
456
+ throw new Error(`input tap failed: ${r.stderr || r.stdout}`);
457
+ }
458
+ else {
459
+ await d.c.performActions(Array.from({ length: n }, () => ({ type: "tap", x, y })));
460
+ }
461
+ }
462
+ async function longPress(d, x, y, ms) {
463
+ await swipe(d, x, y, x, y, ms);
464
+ }
465
+ async function swipe(d, x1, y1, x2, y2, ms) {
466
+ ms = Math.max(1, Math.round(ms));
467
+ if (d.os === "android") {
468
+ const r = await d.c.adbShell("input", ["swipe", `${x1}`, `${y1}`, `${x2}`, `${y2}`, `${ms}`]);
469
+ if (r.exitCode !== 0)
470
+ throw new Error(`input swipe failed: ${r.stderr || r.stdout}`);
471
+ return;
472
+ }
473
+ if (x1 === x2 && y1 === y2) {
474
+ await d.c.performActions([
475
+ { type: "touchDown", x: x1, y: y1 },
476
+ { type: "wait", durationMs: ms },
477
+ { type: "touchUp", x: x1, y: y1 },
478
+ ]);
479
+ return;
480
+ }
481
+ const steps = 10;
482
+ const moves = Array.from({ length: steps }, (_, i) => {
483
+ const t = (i + 1) / steps;
484
+ return [
485
+ { type: "wait", durationMs: Math.round(ms / steps) },
486
+ { type: "touchMove", x: Math.round(x1 + (x2 - x1) * t), y: Math.round(y1 + (y2 - y1) * t) },
487
+ ];
488
+ }).flat();
489
+ await d.c.performActions([{ type: "touchDown", x: x1, y: y1 }, ...moves, { type: "touchUp", x: x2, y: y2 }]);
490
+ }
491
+ /** xdotool names (lowercased) to Limrun. Missing side = not available on that platform. */
492
+ const KEYS = {
493
+ return: { android: "ENTER", ios: "enter" },
494
+ enter: { android: "ENTER", ios: "enter" },
495
+ kp_enter: { android: "ENTER", ios: "enter" },
496
+ backspace: { android: "DEL", ios: "backspace" },
497
+ back_space: { android: "DEL", ios: "backspace" },
498
+ delete: { android: "FORWARD_DEL", ios: "delete" },
499
+ tab: { android: "TAB", ios: "tab" },
500
+ escape: { android: "BACK", ios: "escape" },
501
+ esc: { android: "BACK", ios: "escape" },
502
+ back: { android: "BACK" },
503
+ xf86back: { android: "BACK" },
504
+ home: { android: "HOME", iosButton: "home" },
505
+ super: { android: "HOME", iosButton: "home" },
506
+ super_l: { android: "HOME", iosButton: "home" },
507
+ xf86homepage: { android: "HOME", iosButton: "home" },
508
+ up: { android: "DPAD_UP", ios: "up" },
509
+ down: { android: "DPAD_DOWN", ios: "down" },
510
+ left: { android: "DPAD_LEFT", ios: "left" },
511
+ right: { android: "DPAD_RIGHT", ios: "right" },
512
+ space: { android: "SPACE", ios: "space" },
513
+ xf86audioraisevolume: { android: "VOLUME_UP", iosButton: "volumeUp" },
514
+ volumeup: { android: "VOLUME_UP", iosButton: "volumeUp" },
515
+ volume_up: { android: "VOLUME_UP", iosButton: "volumeUp" },
516
+ xf86audiolowervolume: { android: "VOLUME_DOWN", iosButton: "volumeDown" },
517
+ volumedown: { android: "VOLUME_DOWN", iosButton: "volumeDown" },
518
+ volume_down: { android: "VOLUME_DOWN", iosButton: "volumeDown" },
519
+ xf86audiomute: { android: "VOLUME_MUTE" },
520
+ power: { android: "POWER", iosButton: "lock" },
521
+ xf86poweroff: { android: "POWER", iosButton: "lock" },
522
+ lock: { android: "POWER", iosButton: "lock" },
523
+ menu: { android: "MENU" },
524
+ };
525
+ const MODIFIERS = {
526
+ ctrl: { android: "ctrl", ios: "control" },
527
+ control: { android: "ctrl", ios: "control" },
528
+ control_l: { android: "ctrl", ios: "control" },
529
+ control_r: { android: "ctrl", ios: "control" },
530
+ shift: { android: "shift", ios: "shift" },
531
+ shift_l: { android: "shift", ios: "shift" },
532
+ shift_r: { android: "shift", ios: "shift" },
533
+ alt: { android: "alt", ios: "alt" },
534
+ alt_l: { android: "alt", ios: "alt" },
535
+ option: { android: "alt", ios: "alt" },
536
+ super: { android: "meta", ios: "command" },
537
+ meta: { android: "meta", ios: "command" },
538
+ cmd: { android: "meta", ios: "command" },
539
+ command: { android: "meta", ios: "command" },
540
+ };
541
+ /** @internal "ctrl+Return" on android -> { key: "ENTER", modifiers: ["ctrl"] }. Throws if unavailable. */
542
+ export function limrunKey(chord, os) {
543
+ const parts = chord.split("+").filter(Boolean);
544
+ if (parts.length === 0)
545
+ throw new Error(`empty key "${chord}"`);
546
+ const name = parts[parts.length - 1];
547
+ const modifiers = parts.slice(0, -1).map((m) => {
548
+ const mod = MODIFIERS[m.toLowerCase()];
549
+ if (!mod)
550
+ throw new Error(`unknown modifier "${m}" in "${chord}"`);
551
+ return mod[os];
552
+ });
553
+ const lower = name.toLowerCase();
554
+ const e = KEYS[lower];
555
+ if (os === "android") {
556
+ if (e) {
557
+ if (!e.android)
558
+ throw new Error(`key "${name}" has no Android equivalent`);
559
+ return { key: e.android, modifiers };
560
+ }
561
+ if (/^[0-9]$/.test(name))
562
+ return { key: `KEYCODE_${name}`, modifiers };
563
+ return { key: name.length === 1 ? name : name.toUpperCase(), modifiers };
564
+ }
565
+ if (e) {
566
+ if (e.iosButton) {
567
+ if (modifiers.length)
568
+ throw new Error(`the ${e.iosButton} button can't take modifiers`);
569
+ return { key: lower, modifiers, button: e.iosButton };
570
+ }
571
+ if (!e.ios)
572
+ throw new Error(`key "${name}" has no iOS equivalent${lower === "back" || lower === "xf86back" ? " (swipe from the left edge instead)" : ""}`);
573
+ return { key: e.ios, modifiers };
574
+ }
575
+ return { key: name.length === 1 ? name : lower, modifiers };
576
+ }
577
+ /** Width and height from a PNG IHDR or a JPEG SOF marker. */
578
+ function imageSize(b64) {
579
+ const b = Buffer.from(b64.slice(0, 64 * 1024), "base64");
580
+ if (b.length >= 24 && b.readUInt32BE(0) === 0x89504e47)
581
+ return { width: b.readUInt32BE(16), height: b.readUInt32BE(20) };
582
+ if (b.length >= 4 && b[0] === 0xff && b[1] === 0xd8) {
583
+ let i = 2;
584
+ while (i + 9 < b.length) {
585
+ if (b[i] !== 0xff)
586
+ return {};
587
+ const marker = b[i + 1];
588
+ const len = b.readUInt16BE(i + 2);
589
+ if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
590
+ return { height: b.readUInt16BE(i + 5), width: b.readUInt16BE(i + 7) };
591
+ }
592
+ i += 2 + len;
593
+ }
594
+ }
595
+ return {};
596
+ }
597
+ async function withTemp(fn) {
598
+ const dir = await mkdtemp(join(tmpdir(), "limrun-"));
599
+ try {
600
+ return await fn(join(dir, "file"));
601
+ }
602
+ finally {
603
+ await rm(dir, { recursive: true, force: true }).catch(() => { });
604
+ }
605
+ }
606
+ // "limrun" isn't in the shared ProviderId union yet; the error carries the right name regardless.
607
+ function err(message, status) {
608
+ return new ProviderError("limrun", message, status);
609
+ }
610
+ function wrap(e) {
611
+ if (e instanceof ProviderError)
612
+ return e;
613
+ const status = e?.status;
614
+ return err(e?.message ?? String(e), typeof status === "number" ? status : undefined);
615
+ }
616
+ function isNotFound(e) {
617
+ return e?.status === 404;
618
+ }
619
+ function seconds(n) {
620
+ if (!(n >= 0 && n <= 100))
621
+ throw new Error("duration must be 0 to 100 seconds");
622
+ return n;
623
+ }
624
+ function sleep(ms) {
625
+ return new Promise((r) => setTimeout(r, ms).unref());
626
+ }
@@ -16,11 +16,15 @@ export declare class MaritimeProvider implements Provider, ComputerProvider {
16
16
  readonly computerOs: ComputerOs[];
17
17
  constructor(apiKey?: string | undefined, baseUrl?: string);
18
18
  configured(): boolean;
19
- /** @internal */
19
+ /**
20
+ * @internal Every request has a deadline (`timeoutMs`, default 60 s), so a server that accepts
21
+ * the connection and never answers is an error the router can fall back from, not a hang.
22
+ */
20
23
  request(method: string, url: string, init?: {
21
24
  json?: unknown;
22
25
  bytes?: Uint8Array;
23
26
  headers?: Record<string, string>;
27
+ timeoutMs?: number;
24
28
  }): Promise<Response>;
25
29
  /** @internal */
26
30
  json<T>(method: string, url: string, init?: {