@rebind.gg/mcp-server 0.3.0 → 0.3.2

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,6 +1,6 @@
1
1
  {
2
2
  "name": "@rebind.gg/mcp-server",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "MCP server exposing Rebind as computer-use tools: screenshot, verified click, type, key, scroll, and window control over the Remote Control protocol.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -50,7 +50,7 @@
50
50
  },
51
51
  "dependencies": {
52
52
  "@modelcontextprotocol/sdk": "^1.12.0",
53
- "@rebind.gg/client-ts": "0.3.0",
53
+ "@rebind.gg/client-ts": "0.3.3",
54
54
  "sharp": "^0.34.2",
55
55
  "zod": "^3.25.0"
56
56
  },
@@ -0,0 +1,55 @@
1
+ // The meditate tool's check on model- and page-chosen URLs before they reach
2
+ // relay-side curl (spliced into a shell command inside Lua long-bracket
3
+ // quoting) or the user's browser.
4
+
5
+ import { isIP } from "node:net";
6
+
7
+ /** plain http(s), with nothing that could leave the shell or Lua quoting or
8
+ * that curl reads as a glob ([] and {}); bracketed IPv6 literals stop here */
9
+ const SAFE_URL = /^https?:\/\/[^\s"'`\\\][{}]+$/;
10
+
11
+ // IPv4 ranges that are not public, as [base address, prefix length]
12
+ const RESERVED_V4: [string, number][] = [
13
+ ["0.0.0.0", 8],
14
+ ["10.0.0.0", 8],
15
+ ["100.64.0.0", 10],
16
+ ["127.0.0.0", 8],
17
+ ["169.254.0.0", 16],
18
+ ["172.16.0.0", 12],
19
+ ["192.0.0.0", 24],
20
+ ["192.0.2.0", 24],
21
+ ["192.168.0.0", 16],
22
+ ["198.18.0.0", 15],
23
+ ["198.51.100.0", 24],
24
+ ["203.0.113.0", 24],
25
+ ["224.0.0.0", 3], // multicast and reserved, through 255.255.255.255
26
+ ];
27
+
28
+ const toInt = (ip: string) =>
29
+ ip.split(".").reduce((n, octet) => n * 256 + Number(octet), 0);
30
+ const inRange = (ip: string, [base, bits]: [string, number]) =>
31
+ Math.floor(toInt(ip) / 2 ** (32 - bits)) ===
32
+ Math.floor(toInt(base) / 2 ** (32 - bits));
33
+
34
+ // names that resolve to this machine, the LAN, or a cloud metadata service
35
+ const PRIVATE_NAME = /(^|\.)(localhost|local|internal|home\.arpa)$/;
36
+
37
+ /** Whether a URL may be fetched or opened for the user. Names are checked as
38
+ * written, not resolved, so a public name that points at a private address
39
+ * (127.0.0.1.nip.io, DNS rebinding) still passes, and the browser follows
40
+ * redirects before the landed URL is checked. Relay-side curl runs without
41
+ * -L, so its redirects are not followed. */
42
+ export function isSafeFetchUrl(u: string): boolean {
43
+ if (!SAFE_URL.test(u)) return false;
44
+ let host: string;
45
+ try {
46
+ host = new URL(u).hostname.toLowerCase();
47
+ } catch {
48
+ return false;
49
+ }
50
+ if (host.endsWith(".")) host = host.slice(0, -1);
51
+ // the URL parser has already turned 127.1, 0x7f.0.0.1 and 2130706433
52
+ // into dotted decimal
53
+ if (isIP(host) === 4) return !RESERVED_V4.some((r) => inRange(host, r));
54
+ return host.includes(".") && !PRIVATE_NAME.test(host);
55
+ }
package/src/index.ts CHANGED
@@ -14,6 +14,7 @@ import { RebindRemote, ServerError } from "@rebind.gg/client-ts";
14
14
  import { z } from "zod";
15
15
  import pkg from "../package.json";
16
16
  import { mapToImage } from "./coords.ts";
17
+ import { isSafeFetchUrl } from "./fetch-guard.ts";
17
18
  import { renderScreenshot } from "./image.ts";
18
19
  import { normalizeCombo } from "./keys.ts";
19
20
  import { llmStep, type Msg } from "./llm.ts";
@@ -114,40 +115,11 @@ const MEDITATE_TOTAL_CHARS = 600_000;
114
115
  const MEDITATE_DL_MAX_BYTES = 26_214_400; // 25 MiB
115
116
  const MEDITATE_DL_TIMEOUT_S = 45;
116
117
 
117
- /** URLs the model may visit/download: plain http(s), no quotes/brackets that
118
- * could escape the shell or Lua long-bracket quoting they are spliced into. */
119
- const SAFE_URL = /^https?:\/\/[^\s"'`\\\][]+$/;
120
-
121
118
  /** Front app is a browser we can drive (Safari included — it fails later at the
122
119
  * javascript: step, but it is still a browser, not a terminal we'd type into). */
123
120
  const BROWSER_RE =
124
121
  /chrome|chromium|brave|firefox|edge|opera|vivaldi|arc|safari/i;
125
122
 
126
- /** SSRF guard: block loopback, RFC1918, link-local (incl. cloud metadata), and
127
- * *.local before a model-chosen URL reaches the real browser or relay curl.
128
- * Hostname-based — does not defeat DNS rebinding, but stops the direct hits. */
129
- function isSafeFetchUrl(u: string): boolean {
130
- if (!SAFE_URL.test(u)) return false;
131
- let host: string;
132
- try {
133
- host = new URL(u).hostname.toLowerCase();
134
- } catch {
135
- return false;
136
- }
137
- if (host === "localhost" || host === "0.0.0.0" || host.endsWith(".local"))
138
- return false;
139
- if (
140
- /^(127\.|10\.|192\.168\.|169\.254\.|::1$|\[?::1\]?$|fe80:|fc|fd)/i.test(
141
- host,
142
- )
143
- )
144
- return false;
145
- const m = /^(\d+)\.(\d+)\./.exec(host);
146
- if (m && Number(m[1]) === 172 && Number(m[2]) >= 16 && Number(m[2]) <= 31)
147
- return false;
148
- return true;
149
- }
150
-
151
123
  /** per-asset inlined-content budget fed to the model (text/csv sources) */
152
124
  const MEDITATE_ASSET_CHARS = 60_000;
153
125
  /** a fetched page shorter than this is a JS shell, not content — go to the browser */
@@ -530,6 +502,7 @@ async function main() {
530
502
  // an app finishing launch) and the payload would be typed into it instead.
531
503
  if (!BROWSER_RE.test(await frontApp())) {
532
504
  if (!appName) return null;
505
+ // App.Activate focuses a running browser only; a closed one stays closed
533
506
  await client.luaExec(
534
507
  `App.Activate(${JSON.stringify(appName)}) return true`,
535
508
  );
@@ -649,15 +622,11 @@ async function main() {
649
622
  await sleep(3000);
650
623
  };
651
624
 
652
- // Capture the display under the cursor, render it, and record the image->screen
653
- // mapping on the session so a subsequent verifiedClick(mapImagePoint(...)) lands
654
- // correctly. Returns the webp bytes as a base64 data URL for vision models.
655
- const captureScreen = async (): Promise<{
656
- dataUrl: string;
657
- width: number;
658
- height: number;
659
- }> => {
660
- const cap = await client.screenCapture({});
625
+ // Capture a display (default: the one under the cursor), render it, and record
626
+ // the image->screen mapping on the session so a subsequent
627
+ // verifiedClick(mapImagePoint(...)) lands correctly.
628
+ const captureScreen = async (display?: number) => {
629
+ const cap = await client.screenCapture({ display });
661
630
  const shot = await renderScreenshot({
662
631
  png: Buffer.from(cap.image, "base64"),
663
632
  logicalWidth: cap.display.width,
@@ -675,11 +644,7 @@ async function main() {
675
644
  imageWidth: shot.width,
676
645
  imageHeight: shot.height,
677
646
  };
678
- return {
679
- dataUrl: `data:image/webp;base64,${shot.data.toString("base64")}`,
680
- width: shot.width,
681
- height: shot.height,
682
- };
647
+ return { cap, shot };
683
648
  };
684
649
 
685
650
  // Resolve a window (title substring, or the front window when want=""),
@@ -737,24 +702,7 @@ return { ok = true, proc = proc }
737
702
  },
738
703
  },
739
704
  async ({ display }) => {
740
- const cap = await client.screenCapture({ display });
741
- const shot = await renderScreenshot({
742
- png: Buffer.from(cap.image, "base64"),
743
- logicalWidth: cap.display.width,
744
- logicalHeight: cap.display.height,
745
- cursor: {
746
- x: cap.cursor.x - cap.display.x,
747
- y: cap.cursor.y - cap.display.y,
748
- },
749
- });
750
- session.lastShot = {
751
- displayIndex: cap.display.index,
752
- originX: cap.display.x,
753
- originY: cap.display.y,
754
- scale: shot.scale,
755
- imageWidth: shot.width,
756
- imageHeight: shot.height,
757
- };
705
+ const { cap, shot } = await captureScreen(display);
758
706
  const win = await client.systemWindow().catch(() => null);
759
707
  const lines = [
760
708
  `display ${cap.display.index}: ${cap.display.width}x${cap.display.height} at (${cap.display.x}, ${cap.display.y})${cap.display.primary ? " (primary)" : ""}`,
@@ -1446,7 +1394,8 @@ return { ok = Window.IsActive(h), name = name }
1446
1394
  // Fetch a URL's text relay-side. This is the primary way content is
1447
1395
  // gathered: a GET returns exactly what the server has, where driving the
1448
1396
  // browser to the same URL costs HID, focus, and a fragile extraction.
1449
- // Same SSRF guard and size/time limits as the download path.
1397
+ // Same SSRF guard and size/time limits as the download path, and no -L:
1398
+ // a redirect target is a host the guard never checked.
1450
1399
  const fetchText = async (
1451
1400
  u: string,
1452
1401
  maxChars: number,
@@ -1454,7 +1403,7 @@ return { ok = Window.IsActive(h), name = name }
1454
1403
  if (!isSafeFetchUrl(u)) return null;
1455
1404
  try {
1456
1405
  const out = await client.luaExec(
1457
- `local r = System.Exec([[curl -fsSL --proto '=http,https' --max-filesize ${MEDITATE_DL_MAX_BYTES} --max-time ${MEDITATE_DL_TIMEOUT_S} '${u}']], { timeout = ${(MEDITATE_DL_TIMEOUT_S + 10) * 1000} }) return r.stdout`,
1406
+ `local r = System.Exec([[curl -fsS -g --proto '=http,https' --max-filesize ${MEDITATE_DL_MAX_BYTES} --max-time ${MEDITATE_DL_TIMEOUT_S} '${u}']], { timeout = ${(MEDITATE_DL_TIMEOUT_S + 10) * 1000} }) return r.stdout`,
1458
1407
  );
1459
1408
  const s = String(out ?? "");
1460
1409
  return s.trim() ? s.slice(0, maxChars) : null;
@@ -1567,7 +1516,12 @@ return { ok = Window.IsActive(h), name = name }
1567
1516
  if (haveBrowser)
1568
1517
  await client.luaExec(positionLua("", 0, 0, 1200, 900));
1569
1518
  if (isShell) {
1570
- if (haveBrowser) first = await extractPage(browser, pageUrl);
1519
+ const d = haveBrowser ? await extractPage(browser, pageUrl) : null;
1520
+ if (d && isSafeFetchUrl(d.split("\n")[1] ?? "")) first = d;
1521
+ else if (d)
1522
+ note(
1523
+ "rendered page refused: the browser landed on a host the fetch guard refuses",
1524
+ );
1571
1525
  if (!first) first = raw; // no browser — the shell is all we have
1572
1526
  }
1573
1527
  // PREPEND, never append: the per-page budget keeps only the first
@@ -1649,11 +1603,12 @@ return { ok = Window.IsActive(h), name = name }
1649
1603
  .replace(/[^\w.-]/g, "_")
1650
1604
  .slice(0, 80) || "asset";
1651
1605
  const path = `${dlDir}/${assets.length}-${name}`;
1652
- // isSafeFetchUrl bans quotes/brackets AND private hosts, so this
1653
- // splice is injection-safe; --proto and no -L block redirect-SSRF,
1606
+ // isSafeFetchUrl bans quotes, brackets and braces and private
1607
+ // hosts, and -g turns curl's URL globbing off, so this splice is
1608
+ // injection-safe; --proto and no -L block redirect-SSRF,
1654
1609
  // --max-filesize/--max-time bound a hostile response.
1655
1610
  const exit = await client.luaExec(
1656
- `local r = System.Exec([[curl -fsS --proto '=http,https' --max-filesize ${MEDITATE_DL_MAX_BYTES} --max-time ${MEDITATE_DL_TIMEOUT_S} -o '${path}' '${u}']], { timeout = ${(MEDITATE_DL_TIMEOUT_S + 10) * 1000} }) return r.exit`,
1611
+ `local r = System.Exec([[curl -fsS -g --proto '=http,https' --max-filesize ${MEDITATE_DL_MAX_BYTES} --max-time ${MEDITATE_DL_TIMEOUT_S} -o '${path}' '${u}']], { timeout = ${(MEDITATE_DL_TIMEOUT_S + 10) * 1000} }) return r.exit`,
1657
1612
  );
1658
1613
  const ok = Number(exit) === 0;
1659
1614
  // inline text/csv content so the model can READ the source, not
@@ -1729,7 +1684,11 @@ return { ok = Window.IsActive(h), name = name }
1729
1684
  if (haveBrowser) {
1730
1685
  await gotoUrl(pageUrl);
1731
1686
  try {
1732
- const shot = await captureScreen();
1687
+ const { shot } = await captureScreen();
1688
+ if (!isSafeFetchUrl(await tabUrl(browser || front)))
1689
+ throw new Error(
1690
+ "the browser landed on a URL the fetch guard refuses",
1691
+ );
1733
1692
  const vstep = await llmStep(
1734
1693
  [
1735
1694
  { role: "system", content: MEDITATE_VISION_SYS },
@@ -1737,7 +1696,12 @@ return { ok = Window.IsActive(h), name = name }
1737
1696
  role: "user",
1738
1697
  content: [
1739
1698
  { type: "text", text: "Read this page." },
1740
- { type: "image_url", image_url: { url: shot.dataUrl } },
1699
+ {
1700
+ type: "image_url",
1701
+ image_url: {
1702
+ url: `data:image/webp;base64,${shot.data.toString("base64")}`,
1703
+ },
1704
+ },
1741
1705
  ],
1742
1706
  },
1743
1707
  ],
@@ -1756,7 +1720,7 @@ return { ok = Window.IsActive(h), name = name }
1756
1720
  }
1757
1721
  } catch (e) {
1758
1722
  note(
1759
- `vision pass skipped (model may lack image input): ${e instanceof Error ? e.message : e}`,
1723
+ `vision pass skipped: ${e instanceof Error ? e.message : e}`,
1760
1724
  );
1761
1725
  }
1762
1726
  }
package/src/rebind.d.luau CHANGED
@@ -190,10 +190,10 @@ export type InputDevice = {
190
190
  id: string,
191
191
  name: string,
192
192
  nickname: string?,
193
- kind: "keyboard" | "mouse" | "other",
193
+ kind: "keyboard" | "mouse" | "gamepad" | "joystick" | "other",
194
194
  vid: string,
195
195
  pid: string,
196
- source: "vm" | "host",
196
+ source: "vm",
197
197
  exact: boolean,
198
198
  }
199
199
 
@@ -445,9 +445,8 @@ export type RebindInfo = {
445
445
  version: string,
446
446
  name: string,
447
447
  runtime_uuid: string,
448
- id: string?,
449
448
  instance: string,
450
- -- host operating system: "windows" | "macos" | "linux"
449
+ -- host operating system: "windows" | "macos"
451
450
  platform: string,
452
451
  }
453
452
 
@@ -541,9 +540,153 @@ export type RbPhone = {
541
540
  notify: (text: string, title: string?) -> PhoneResult,
542
541
  }
543
542
 
543
+ -- a colour is "#rrggbb" or "#rrggbbaa"; in widget text it may also be a theme
544
+ -- role: "text", "dim", "faint", "accent", "positive", "warning" or "danger"
545
+ export type OverlaySpan = string | { any }
546
+
547
+ -- a string (a newline starts a line) or one line of spans
548
+ export type OverlayText = string | { OverlaySpan }
549
+
550
+ -- one draw operation in a surface, in logical units from its top-left corner
551
+ export type OverlayOp = {
552
+ kind: "rect" | "text",
553
+ x: number?,
554
+ y: number?,
555
+ -- rect
556
+ w: number?,
557
+ h: number?,
558
+ fill: string?,
559
+ border: string?,
560
+ border_width: number?,
561
+ radius: number?,
562
+ -- true (50% black), false, or the shadow's opacity from 0 to 1
563
+ shadow: (boolean | number)?,
564
+ -- text: one line, as text plus color and bold, or as spans
565
+ text: string?,
566
+ spans: { OverlaySpan }?,
567
+ color: string?,
568
+ bold: boolean?,
569
+ size: number?,
570
+ }
571
+
572
+ -- where a surface goes: x and y in rb.screen coordinates, or an anchor
573
+ -- ("top-left", "top", "top-right", "left", "center", "right", "bottom-left",
574
+ -- "bottom", "bottom-right") on a display ("active": the foreground window's
575
+ -- monitor, "primary", "mouse"), moved offset_x and offset_y inward
576
+ export type OverlayPlacement = {
577
+ x: number?,
578
+ y: number?,
579
+ anchor: string?,
580
+ display: ("active" | "primary" | "mouse")?,
581
+ offset_x: number?,
582
+ offset_y: number?,
583
+ }
584
+
585
+ export type OverlaySurfaceOptions = OverlayPlacement & {
586
+ width: number,
587
+ height: number,
588
+ opacity: number?,
589
+ ops: { OverlayOp }?,
590
+ }
591
+
592
+ export type OverlaySurface = {
593
+ -- merges fields into the surface; x/y and anchor/display replace each other
594
+ set: (self: OverlaySurface, fields: { [string]: any }) -> OverlaySurface,
595
+ draw: (self: OverlaySurface, ops: { OverlayOp }) -> OverlaySurface,
596
+ remove: (self: OverlaySurface) -> (),
597
+ }
598
+
599
+ -- shared by the widgets; theme is a name from rb.overlay.themes or a table
600
+ -- of the same roles
601
+ export type OverlayWidgetOptions = OverlayPlacement & {
602
+ theme: (string | { [string]: any })?,
603
+ size: number?,
604
+ }
605
+
606
+ export type OverlayToastOptions = OverlayWidgetOptions & {
607
+ title: string?,
608
+ meta: string?,
609
+ -- ms on screen (default 4000)
610
+ duration: number?,
611
+ -- ms to wait for hidden overlays to show before giving up (default 60000)
612
+ expires: number?,
613
+ max_width: number?,
614
+ }
615
+
616
+ export type OverlayToast = {
617
+ close: () -> (),
618
+ }
619
+
620
+ export type OverlayMenuItem = string | {
621
+ label: string,
622
+ hint: string?,
623
+ action: ((index: number, item: any) -> ())?,
624
+ }
625
+
626
+ export type OverlayMenuOptions = OverlayWidgetOptions & {
627
+ title: string?,
628
+ subtitle: string?,
629
+ width: number?,
630
+ keep_open: boolean?,
631
+ on_select: ((index: number, item: any) -> ())?,
632
+ on_close: (() -> ())?,
633
+ }
634
+
635
+ export type OverlayMenu = {
636
+ selected: number,
637
+ select: (index: number) -> (),
638
+ close: () -> (),
639
+ }
640
+
641
+ export type OverlayStatus = {
642
+ set: (text: OverlayText) -> OverlayStatus,
643
+ remove: () -> (),
644
+ }
645
+
646
+ export type OverlayProgressOptions = OverlayWidgetOptions & {
647
+ -- logical units (default 320)
648
+ width: number?,
649
+ }
650
+
651
+ export type OverlayProgress = {
652
+ set: (fraction: number?, label: string?) -> OverlayProgress,
653
+ remove: () -> (),
654
+ }
655
+
656
+ export type RbOverlay = {
657
+ -- a surface you draw yourself; at most 16 per script covering at most
658
+ -- 2,000,000 square units together, removed when the script stops
659
+ surface: (options: OverlaySurfaceOptions) -> OverlaySurface,
660
+ -- width and height of one line of text in the bundled monospace face
661
+ measure: (text: string, size: number?, bold: boolean?) -> (number, number),
662
+ -- false only when the OS is known to hide overlays (Windows: an exclusive
663
+ -- full-screen Direct3D game; macOS: the overlays on screen covered for 2
664
+ -- seconds, so with none on screen it is true)
665
+ visible: () -> boolean,
666
+ -- removes every surface of this script
667
+ clear: () -> (),
668
+ -- "rebind", "rebind-light", "catppuccin", "tokyo-night", "contrast"
669
+ themes: { [string]: { [string]: any } },
670
+ -- a timed notification; toasts at one anchor stack, and one asked for while
671
+ -- overlays are hidden waits until they show or it expires. A waiting toast
672
+ -- that then cannot show is dropped with a warning in the script's log.
673
+ toast: (text: OverlayText, options: OverlayToastOptions?) -> OverlayToast,
674
+ -- a keyboard menu: while open it takes Up, Down, Enter, Escape and 1 to 9 and
675
+ -- swallows them. A pick closes it (unless keep_open), then runs the item's
676
+ -- action and on_select. Losing focus or the OS hiding overlays also closes
677
+ -- it; on_close runs for every close except rb.overlay.clear() and stopping.
678
+ -- nil and a reason when overlays are hidden, the script is unfocused, or a
679
+ -- menu is already open, in this script or another.
680
+ menu: (items: { OverlayMenuItem }, options: OverlayMenuOptions?) -> (OverlayMenu?, string?),
681
+ -- a one-line strip that stays until removed
682
+ status: (text: OverlayText, options: OverlayWidgetOptions?) -> OverlayStatus,
683
+ -- a labelled bar, fraction 0 to 1
684
+ progress: (label: string, fraction: number?, options: OverlayProgressOptions?) -> OverlayProgress,
685
+ }
686
+
544
687
  -- events for rb.on (Rebind 3.9.0 and later), with the arguments each
545
- -- handler receives. For down, up, move and scroll, a handler that returns
546
- -- false swallows the input.
688
+ -- handler receives. For down, up, scroll and axis, a handler that returns
689
+ -- false swallows the input; for move only with mouse_block=true.
547
690
  export type Event =
548
691
  | "start" -- ()
549
692
  | "stop" -- ()
@@ -557,6 +700,7 @@ export type Event =
557
700
  | "move" -- (dx: number, dy: number, device: InputDevice?) hardware mode only
558
701
  | "scroll" -- (delta: number, device: InputDevice?)
559
702
  | "scroll_h" -- (delta: number)
703
+ | "axis" -- (axis: string, value: number, device: InputDevice?) hardware mode only
560
704
  | "input_down" -- (key: string, device: InputDevice?)
561
705
  | "input_up" -- (key: string, duration_ms: number, device: InputDevice?)
562
706
  | "input_move" -- (dx: number, dy: number, device: InputDevice?)
@@ -577,9 +721,19 @@ export type Event =
577
721
  -- system: PrintScreen, Pause, Menu
578
722
  -- numpad: Kp0-Kp9, KpPlus, KpMinus, KpMultiply, KpDivide, KpEnter, KpDot
579
723
  --
580
- -- HID output only:
724
+ -- delivered as input only in hardware mode:
581
725
  -- function: F13-F24
582
- -- media: MediaNext, MediaPrev, MediaStop, MediaPlay, VolumeUp, VolumeDown, Mute
726
+ -- media: MediaNext, MediaPrev, MediaStop, MediaPlay, VolumeUp, VolumeDown, VolumeMute
727
+ --
728
+ -- game controllers, hardware mode only (named by position, Xbox letters):
729
+ -- buttons: PadA (bottom), PadB (right), PadX (left), PadY (top), PadLB,
730
+ -- PadRB, PadLT, PadRT, PadView, PadMenu, PadHome, PadLS, PadRS,
731
+ -- PadUp, PadDown, PadLeft, PadRight, PadP1-PadP4 (paddles)
732
+ -- other pads: Joy1-Joy32, in the order the controller reports them
733
+ -- axes: LeftX, LeftY, RightX, RightY (-1..1, up and left negative),
734
+ -- LT, RT (0..1); the "axis" event and input.axis use these
735
+ --
736
+ -- HID output and bind chord modifiers only:
583
737
  -- aliases: Numpad0-9, NP0-9, NPAdd, NPSubtract, NPMultiply, NPDivide, NPEnter,
584
738
  -- NPDecimal, Ctrl, Shift, Alt, Win, Meta, Del, PgUp, PgDn, etc.
585
739
 
@@ -647,21 +801,36 @@ declare rb: {
647
801
  type: (text: string) -> (),
648
802
  typewriter: (text: string, delay_ms: number?) -> (),
649
803
  move: (dx: number, dy: number) -> (),
650
- move_to: (x: number, y: number) -> (),
804
+ -- exact in Windows and macOS software mode; on a Rebind Link it moves,
805
+ -- rereads the cursor and corrects until within 2 px (about 150 ms at
806
+ -- most), waiting with sleep in rb.run and blocking elsewhere. With ms
807
+ -- it glides there over ms on an eased path; a duration needs rb.run or
808
+ -- rb.async.
809
+ move_to: (x: number, y: number, ms: number?) -> (),
651
810
  scroll: (amount: number) -> (),
811
+ -- deprecated: the mode is stored but no longer changes move_to
652
812
  set_mouse_mode: (mode: string) -> (),
653
813
  get_mouse_mode: () -> string,
654
814
  -- keyboard HID usage for a key name, or nil (media keys return nil). down,
655
815
  -- up, press and combo also accept "usage:N" (decimal or 0x hex, 0x04-0xE7).
656
816
  usage: (name: string) -> number?,
657
817
  -- sets the whole joypad state (last write wins across scripts). Needs a
658
- -- Rebind Link whose USB profile has a joypad; raises otherwise.
818
+ -- Rebind Link whose USB profile has a joypad; raises otherwise. down, up,
819
+ -- press and combo also press controller buttons ("PadA").
659
820
  joypad: (state: JoypadState?) -> (),
660
- -- back to neutral; the relay also does this when a script stops or errors
821
+ -- holds one controller axis (LeftX..RightY -1..1, LT/RT 0..1) until set
822
+ -- again or reset; it wins over a captured controller's value
823
+ axis: (name: string, value: number) -> (),
824
+ -- drops what scripts set, keeping what a captured controller holds; the
825
+ -- relay also does this when a script stops or errors
661
826
  joypad_reset: () -> (),
662
827
  },
663
828
  input: {
664
829
  is_down: (code: string) -> boolean,
830
+ -- a controller axis by name, 0 at rest
831
+ axis: (name: string) -> number,
832
+ -- the devices Rebind has identified (3.12.0); empty in software mode
833
+ devices: () -> { InputDevice },
665
834
  duration: (code: string) -> number,
666
835
  keys: () -> { string },
667
836
  mouse_pos: () -> MousePos,
@@ -763,8 +932,9 @@ declare rb: {
763
932
  wait_close: (title: string, timeout: number?) -> boolean,
764
933
  },
765
934
  -- application control. macOS matches app display names ignoring case;
766
- -- Windows matches executable names (".exe" optional) and cannot hide;
767
- -- Linux raises on every call.
935
+ -- Windows matches executable names (".exe" optional) and cannot hide.
936
+ -- activate focuses a running app only (it needs Accessibility on macOS);
937
+ -- start one with rb.system.exec_detached (permission: exec).
768
938
  app: {
769
939
  front: () -> AppInfo?,
770
940
  is_front: (name: string) -> boolean,
@@ -784,13 +954,15 @@ declare rb: {
784
954
  head: (url: string, options: RbHttpOptions?) -> HttpResponse,
785
955
  request: (options: HttpRequestOptions) -> HttpResponse,
786
956
  listen: (port: number, handler: (request: HttpRequest) -> HttpResponseSpec | string) -> ServerHandle,
787
- ws_listen: (port: number, handlers: WsListenHandlers) -> WsServerHandle,
957
+ -- options.host binds one IP ("127.0.0.1" keeps it on this computer); default all interfaces
958
+ ws_listen: (port: number, handlers: WsListenHandlers, options: { host: string? }?) -> WsServerHandle,
788
959
  ws_connect: (url: string, handlers: WsConnectHandlers) -> WsConnectHandle,
789
960
  -- binds a UDP socket; handler receives each datagram's raw bytes and sender.
790
961
  udp_listen: (bind_ip: string, port: number, handler: (data: string, ip: string, port: number) -> ()) -> UdpHandle,
791
962
  -- this computer's IPv4 address on its default route, or nil when there is none.
792
963
  local_ip: () -> string?,
793
964
  },
965
+ -- permission: exec
794
966
  pipe: {
795
967
  open: (name: string, options: { size: number? }?) -> PipeHandle,
796
968
  },
@@ -805,7 +977,7 @@ declare rb: {
805
977
  -- nil path lists the script's own directory
806
978
  list: (path: string?) -> { string },
807
979
  mkdir: (path: string) -> boolean,
808
- -- recursive; returns true even when the directory did not exist.
980
+ -- recursive; raises when the directory does not exist or cannot be removed.
809
981
  rmdir: (path: string) -> boolean,
810
982
  copy: (src: string, dst: string) -> (),
811
983
  move: (src: string, dst: string) -> (),
@@ -872,6 +1044,7 @@ declare rb: {
872
1044
  },
873
1045
  env: {
874
1046
  get: (name: string) -> string?,
1047
+ -- Requires the 'exec' permission.
875
1048
  set: (name: string, value: string) -> (),
876
1049
  home: () -> string?,
877
1050
  config: () -> string?,
@@ -893,6 +1066,8 @@ declare rb: {
893
1066
  -- Rebind Pro; require("@rebind/pro") returns rb
894
1067
  ai: RbAi,
895
1068
  phone: RbPhone,
1069
+ -- drawing on screen over every app, games included (Windows and macOS)
1070
+ overlay: RbOverlay,
896
1071
  }
897
1072
 
898
1073
  -- v1 names
@@ -954,8 +1129,13 @@ declare HID: {
954
1129
  Type: (text: string) -> (),
955
1130
  Typewriter: (text: string, delayMs: number?) -> (),
956
1131
  Move: (dx: number, dy: number) -> (),
957
- MoveTo: (x: number, y: number) -> (),
1132
+ -- exact in Windows and macOS software mode; on a Rebind Link it moves,
1133
+ -- rereads the cursor and corrects until within 2 px (about 150 ms at
1134
+ -- most), waiting with Sleep in Run and blocking elsewhere. With ms it
1135
+ -- glides there over ms on an eased path; a duration needs Run or Async.
1136
+ MoveTo: (x: number, y: number, ms: number?) -> (),
958
1137
  Scroll: (amount: number) -> (),
1138
+ -- deprecated: the mode is stored but no longer changes MoveTo
959
1139
  SetMouseMode: (mode: string) -> (),
960
1140
  GetMouseMode: () -> string,
961
1141
  -- keyboard HID usage for a key name, or nil (media keys return nil). Down,
@@ -964,8 +1144,13 @@ declare HID: {
964
1144
  -- set the whole joypad state (last write wins across scripts). Needs a
965
1145
  -- Rebind Link whose USB profile has a joypad; raises otherwise. Keyboard
966
1146
  -- and mouse calls raise the same way when the profile leaves them out.
1147
+ -- Down, Up, Press and Combo also press controller buttons ("PadA").
967
1148
  Joypad: (state: JoypadState?) -> (),
968
- -- back to neutral; the relay also does this when a script stops or errors
1149
+ -- hold one controller axis (LeftX..RightY -1..1, LT/RT 0..1) until set
1150
+ -- again or reset; it wins over a captured controller's value
1151
+ Axis: (name: string, value: number) -> (),
1152
+ -- drop what scripts set, keeping what a captured controller holds; the
1153
+ -- relay also does this when a script stops or errors
969
1154
  JoypadReset: () -> (),
970
1155
  }
971
1156
 
@@ -973,6 +1158,10 @@ declare HID: {
973
1158
 
974
1159
  declare Input: {
975
1160
  IsDown: (code: string) -> boolean,
1161
+ -- a controller axis by name, 0 at rest
1162
+ GetAxis: (name: string) -> number,
1163
+ -- the devices Rebind has identified (3.12.0); empty in software mode
1164
+ Devices: () -> { InputDevice },
976
1165
  GetDuration: (code: string) -> number,
977
1166
  GetActiveKeys: () -> { string },
978
1167
  GetMousePos: () -> MousePos,
@@ -1118,12 +1307,13 @@ declare Window: {
1118
1307
  }
1119
1308
 
1120
1309
  -- app namespace: application-level control (Keyboard Maestro's Application
1121
- -- Control). macOS: names match case-insensitively by app display name;
1122
- -- Front/IsRunning/Activate are permission-free; Hide/Quit/IsHidden drive an
1123
- -- AppleEvent and prompt for macOS Automation permission on first use.
1310
+ -- Control). Activate focuses a running app and never launches one; start an
1311
+ -- app with System.ExecDetached (permission: exec). macOS: names match
1312
+ -- case-insensitively by app display name; Activate needs Accessibility;
1313
+ -- Quit/IsHidden drive an AppleEvent and prompt for macOS Automation
1314
+ -- permission on first use; Quit leaves an app that is not running alone.
1124
1315
  -- Windows: names are executable names (case-insensitive, ".exe" optional);
1125
- -- Activate focuses a running app and does not launch one; Quit closes its
1126
- -- windows; Hide/IsHidden raise. Linux: every call raises.
1316
+ -- Quit closes its windows; Hide/IsHidden raise.
1127
1317
 
1128
1318
  declare App: {
1129
1319
  Front: () -> AppInfo?,
@@ -1146,7 +1336,7 @@ declare Net: {
1146
1336
  Head: (url: string, headers: { [string]: string }?, options: HttpOptions?) -> HttpResponse,
1147
1337
  Request: (options: HttpRequestOptions) -> HttpResponse,
1148
1338
  Listen: (port: number, handler: (request: HttpRequest) -> HttpResponseSpec | string) -> ServerHandle,
1149
- WSListen: (port: number, handlers: WsListenHandlers) -> WsServerHandle,
1339
+ WSListen: (port: number, handlers: WsListenHandlers, options: { host: string? }?) -> WsServerHandle,
1150
1340
  WSConnect: (url: string, handlers: WsConnectHandlers) -> WsConnectHandle,
1151
1341
  -- bind a UDP socket; handler receives each datagram's raw bytes and sender.
1152
1342
  UDPListen: (bindIp: string, port: number, handler: (data: string, ip: string, port: number) -> ()) -> UdpHandle,
@@ -1154,7 +1344,7 @@ declare Net: {
1154
1344
  LocalIP: () -> string?,
1155
1345
  }
1156
1346
 
1157
- -- pipe namespace
1347
+ -- pipe namespace (permission: exec)
1158
1348
 
1159
1349
  declare Pipe: {
1160
1350
  Open: (name: string, options: { size: number? }?) -> PipeHandle,
@@ -1173,7 +1363,7 @@ declare File: {
1173
1363
  -- script dir (the File.* jail); absolute paths are rejected.
1174
1364
  List: (path: string?) -> { string },
1175
1365
  MkDir: (path: string) -> boolean,
1176
- -- recursive; returns true even when the directory did not exist.
1366
+ -- recursive; raises when the directory does not exist or cannot be removed.
1177
1367
  RmDir: (path: string) -> boolean,
1178
1368
  Copy: (src: string, dst: string) -> (),
1179
1369
  Move: (src: string, dst: string) -> (),
@@ -1266,6 +1456,7 @@ declare Codec: {
1266
1456
 
1267
1457
  declare Env: {
1268
1458
  Get: (name: string) -> string?,
1459
+ -- Requires the 'exec' permission.
1269
1460
  Set: (name: string, value: string) -> (),
1270
1461
  Home: () -> string?,
1271
1462
  Config: () -> string?,
@@ -1300,6 +1491,7 @@ declare Registry: {
1300
1491
  -- declare function OnMove(dx: number, dy: number, device: InputDevice?): boolean? -- return value only used with mouse_block=true (hardware mode only)
1301
1492
  -- declare function OnScroll(delta: number, device: InputDevice?): boolean?
1302
1493
  -- declare function OnScrollH(delta: number): boolean?
1494
+ -- declare function OnAxis(axis: string, value: number, device: InputDevice?): boolean? -- hardware mode only
1303
1495
  -- declare function OnInputDown(key: string, device: InputDevice?): ()
1304
1496
  -- declare function OnInputUp(key: string, durationMs: number, device: InputDevice?): ()
1305
1497
  -- declare function OnInputMove(dx: number, dy: number, device: InputDevice?): ()
package/src/session.ts CHANGED
@@ -87,12 +87,12 @@ export class Session {
87
87
  /**
88
88
  * Move the OS cursor to absolute screen (x, y).
89
89
  *
90
- * The relay's hid.move_smooth / hid.move_to derive their delta from a cached
91
- * cursor position that goes stale and flings the pointer to a screen edge
92
- * (observed: target (800,500) landing at (0,0)). Relative counts map 1:1 to
93
- * pixels, so we read the REAL cursor, glide the residual in small relative
94
- * steps (human-like, not an instant jump), then read back and correct against
95
- * the true cursor. Converges to the exact pixel, usually in a single pass.
90
+ * Relays before 3.10.1 derived hid.move_to's move from a stale
91
+ * cached cursor and could fling the pointer to a screen edge (observed:
92
+ * target (800,500) landing at (0,0)), so this reads the REAL cursor, glides
93
+ * the residual in small relative steps (human-like, not an instant jump),
94
+ * then reads back and corrects against the true cursor. Converges to the
95
+ * exact pixel, usually in a single pass.
96
96
  */
97
97
  async moveTo(x: number, y: number): Promise<Point & { passes: number }> {
98
98
  await this.ensureCalibrated();