Remote Control

@rebindv0.6.0

Connect your programs to Rebind over WebSocket using the documented remote-control server. Works with the client libraries and MCP.

Files

main.luau

--[[ overview

  the successor to remote_access.lua. same wire protocol (json over websocket),
  but every sdk namespace is exposed rather than the original six, and the
  dispatch layer is rewritten: auth, danger-gating, and async handling are
  enforced centrally, so each command is a single line of intent.

  protocol: json messages over websocket. every request may carry an `id`; when
  present the server replies with the same id so the client can correlate.

    {"t":"hid.down", "code":"A"}                    one-shot
    {"t":"screen.pixel", "id":7, "x":100, "y":50}   request/response
    {"t":"subscribe", "events":["mouse","input"]}   push stream
    {"t":"commands", "id":1}                         introspect the surface

  backward compatible with remote_access.lua v1.1 clients: every v1 command name
  and response shape is preserved. new namespaces are additive.

  this package is the only copy of the server; rebind.toml owns its metadata.
  bump PROTOCOL_VERSION here when the command surface changes.
]]

--[[ user config

  every setting is a field in Options. values persist in the relay's own
  settings store, outside this script's File jail, and ui.* over the wire is
  read-only, so no command changes them. three exceptions are deliberate:
  shell commands, file access and custom luau each hand a client full control
  (custom luau can call UI.Set, file access can rewrite main.luau, shell
  commands can edit anything), and an approved client with input control can
  click Options like a person would.

    allow_exec      System.Exec / System.ExecDetached / Process.Kill / Env.Set.
    allow_file      File.* and Config.ReadTOML/WriteTOML (script directory).
    allow_registry  Registry writes/deletes (windows). reads stay open.
    allow_lua_exec  lua.exec, arbitrary luau in this script. highest risk.
    allow_observe   observe.*: record every key and the screen (observe.luau).
    local_only      bind 127.0.0.1 instead of every interface (restart).
    ask_new_apps    a native prompt before a new program may send commands.
    approved_apps   names allowed before, comma-separated; clear to forget.

  the auth token is redacted from ui.get / ui.get_all so an authenticated
  client can never read it back, and the compare is done over sha256 so timing
  can't leak its length or prefix.

  approval keys on the name a program declares in hello. any program can
  claim any name, so the token stays the security boundary; with a blank
  token, a web page in a browser on this computer can connect too.
]]

local PROTOCOL_VERSION = "2.2.0"

local cfg = UI.Schema({
  port = UI.Slider(19561, { min = 1024, max = 65535, label = "WS port" }),
  auth_token = UI.Text("", {
    label = "Auth token",
    placeholder = "blank = no token",
    tooltip = 'when set, clients must send { t="auth", token="..." } first. redacted from ui.get.',
  }),
  local_only = UI.Toggle(true, {
    label = "Local only",
    tooltip = "only programs on this computer can connect. restart after changing.",
  }),
  ask_new_apps = UI.Toggle(true, {
    label = "Ask before a new app connects",
    tooltip = "show a prompt before a program you have not allowed can send commands",
  }),
  approved_apps = UI.Text("", {
    label = "Approved apps",
    tooltip = "programs you allowed, comma-separated. clear to be asked again.",
  }),
  allow_exec = UI.Toggle(false, { label = "Allow shell commands" }),
  allow_file = UI.Toggle(false, { label = "Allow file access" }),
  allow_registry = UI.Toggle(false, { label = "Allow registry changes" }),
  allow_lua_exec = UI.Toggle(false, {
    label = "Allow custom Luau",
    tooltip = "lua.exec runs any code in this script and overrides every other setting",
  }),
  allow_observe = UI.Toggle(false, {
    label = "Allow observation",
    tooltip = "lets a connected program record every key you press and your screen",
  }),
})

-- gate name -> the Options toggle that enables it, read live on every call.
local GATES = {
  exec = { key = "allow_exec", label = "Allow shell commands" },
  file = { key = "allow_file", label = "Allow file access" },
  registry = { key = "allow_registry", label = "Allow registry changes" },
  lua = { key = "allow_lua_exec", label = "Allow custom Luau" },
  observe = { key = "allow_observe", label = "Allow observation" },
}

-- keys never echoed back over the wire (secrets living in the ui value store).
local REDACTED = { auth_token = true }

-- read the token live so panel edits take effect without a reload.
local function auth_token()
  return cfg.auth_token or ""
end

-- compare via fixed-width sha256 hex so the check time can't leak the token's
-- length or a matching prefix (a plain == short-circuits on first mismatch).
local function token_ok(given)
  local expected = auth_token()
  if expected == "" then
    return true
  end
  return Hash.SHA256(given or "") == Hash.SHA256(expected)
end

-- common banner: what the server is and what it currently allows.
local function server_banner()
  local gates = {}
  for name, gate in pairs(GATES) do
    gates[name] = cfg[gate.key] == true
  end
  return {
    protocol = PROTOCOL_VERSION,
    platform = _REBIND.platform,
    auth_required = auth_token() ~= "",
    gates = gates,
  }
end

--[[ server state

  clients      client_id -> live client handle, for server-initiated pushes.
  authed       client_id -> true once verified (or when the token is empty).
  subscribers  client_id -> { client, mouse, window, input } stream flags.
  timers       client_id -> { [handle_id] = timer_handle }; cancelled on close.
  sounds       sound_id  -> SoundHandle, for control-by-id.
  next_id      shared counter backing server-side timer/sound handle ids.
  app_names    client_id -> name the client declared in hello.
  approval     client_id -> "pending" | "approved" | "denied".
  asking       true while an approval prompt is open (one at a time).
]]
local server = nil
local clients = {}
local authed = {}
local subscribers = {}
local timers = {}
local sounds = {}
local next_id = 0
local app_names = {}
local approval = {}
local asking = false

local last_mouse_x, last_mouse_y, last_window_title = nil, nil, nil

--[[ json encode

  JSON.Stringify encodes an empty lua table as {} not [], which breaks clients
  expecting an array (empty key list, no windows, no matches). this encoder
  treats any table whose keys are exactly 1..#t as an array — including the
  empty table, which in this protocol is always an empty list. objects (string
  keys) always carry at least one field, so they never collide with that rule.
]]

local ESCAPES = {
  ['"'] = '\\"',
  ["\\"] = "\\\\",
  ["\n"] = "\\n",
  ["\r"] = "\\r",
  ["\t"] = "\\t",
  ["\b"] = "\\b",
  ["\f"] = "\\f",
}

local function encode_str(s)
  return '"'
    .. s:gsub('[%z\1-\31\\"]', function(c)
      return ESCAPES[c] or string.format("\\u%04x", c:byte())
    end)
    .. '"'
end

local function encode(v)
  local t = type(v)
  if v == nil then
    return "null"
  elseif t == "boolean" then
    return v and "true" or "false"
  elseif t == "number" then
    if v ~= v or v == math.huge or v == -math.huge then
      return "null"
    end
    if v == math.floor(v) and math.abs(v) < 1e15 then
      return string.format("%.0f", v)
    end
    return string.format("%.14g", v)
  elseif t == "string" then
    return encode_str(v)
  elseif t == "table" then
    local n = #v
    local count = 0
    for _ in pairs(v) do
      count += 1
    end
    if count == n then
      local parts = {}
      for i = 1, n do
        parts[i] = encode(v[i])
      end
      return "[" .. table.concat(parts, ",") .. "]"
    end
    local parts = {}
    for k, val in pairs(v) do
      parts[#parts + 1] = encode_str(tostring(k)) .. ":" .. encode(val)
    end
    return "{" .. table.concat(parts, ",") .. "}"
  end
  return "null" -- function / thread / userdata
end

--[[ wire helpers ]]

local function send(client, obj)
  client:Send(encode(obj))
end

local function reply(client, req, payload)
  if req.id == nil then
    return
  end
  payload.id = req.id
  send(client, payload)
end

local function err(client, req, code, message)
  if req.id == nil then
    Log.Warn(`remote: client {client.id} error ({code}): {message}`)
    return
  end
  send(client, { id = req.id, error = { code = code, message = message } })
end

local function hex_rgb(hex)
  return tonumber(hex:sub(1, 2), 16) or 0,
    tonumber(hex:sub(3, 4), 16) or 0,
    tonumber(hex:sub(5, 6), 16) or 0
end

local function alloc_id()
  next_id += 1
  return next_id
end

--[[ command registry

  a command is `def(name, fn, opts)` where opts = { open?, async?, gate? }:

    fn(client, req)  returns a payload table on success, or (nil, code, msg) for
                     a typed error. a nil return is a void "ok".
    open   command is reachable before auth (handshake / ping only).
    async  fn yields (Net.*, Window.Wait*, System.Exec, Screen.Capture,
           Screen.FindImage, coroutine HID) — run in Run().
    gate   name of a GATES flag that must be true, else "disabled".

  auth, gating, error shaping, and replying are all handled by the dispatcher,
  so each fn below is pure intent.
]]

local api = {}

local function def(name, fn, opts)
  opts = opts or {}
  opts.fn = fn
  api[name] = opts
end

--[[ handshake & meta ]]

--[[ approval

  a program runs commands only after the person at this computer allows it.
  the prompt names the program by what it declared in hello; allowed names are
  kept in approved_apps. nothing is queued while the prompt is open: commands
  get awaiting_approval and the program retries, so a late allow never fires
  stale input.
]]

local PROMPT_TIMEOUT_MS = 120000

-- strip what could forge the prompt's wording or split the approved list:
-- control characters, quotes, commas and the unicode bidi overrides. cut at
-- 64 characters on a utf-8 boundary, then trim.
local function clean_name(name)
  if type(name) ~= "string" then
    return nil
  end
  name = name
    :gsub('[%c",]', "")
    :gsub("\226\128[\170-\174]", "")
    :gsub("\226\129[\166-\169]", "")
  local stop = utf8.offset(name, 65)
  if stop then
    name = name:sub(1, stop - 1)
  end
  name = name:match("^%s*(.-)%s*$")
  return name ~= "" and name or nil
end

local function remembered(name)
  if not name then
    return false
  end
  for entry in (cfg.approved_apps or ""):gmatch("[^,]+") do
    if entry:match("^%s*(.-)%s*$") == name then
      return true
    end
  end
  return false
end

local function remember(name)
  if remembered(name) then
    return
  end
  local list = cfg.approved_apps or ""
  UI.Set("approved_apps", list == "" and name or `{list}, {name}`)
end

-- inside the relay on macos Dialog.Confirm never returns (the native alert
-- waits on a main run loop the relay does not run), so ask through osascript
-- there. the text reaches osascript as one single-quoted shell word holding an
-- applescript string literal, so a declared name cannot break out of either.
local function confirm(text)
  if _REBIND.platform ~= "macos" then
    return Dialog.Confirm(text, { title = "Remote Control" })
  end
  local literal = '"' .. text:gsub("\\", "\\\\"):gsub('"', '\\"') .. '"'
  local script = "display dialog "
    .. literal
    .. ' with title "Remote Control" buttons {"Deny", "Allow"}'
    .. ' default button "Deny" cancel button "Deny"'
    .. " giving up after "
    .. tostring(PROMPT_TIMEOUT_MS // 1000 - 5)
  local result = System.Exec(
    "osascript -e '" .. script:gsub("'", "'\\''") .. "'",
    { timeout = PROMPT_TIMEOUT_MS }
  )
  -- nil when the prompt gave up unanswered, so it is asked again
  if result.exit == 0 and result.stdout:find("gave up:true", 1, true) then
    return nil
  end
  return result.exit == 0
    and result.stdout:find("button returned:Allow", 1, true) ~= nil
end

-- one prompt at a time. the timer releases a prompt that never returns, so an
-- expired one is asked again on the next command.
local function ask(client_id, name)
  asking = true
  local released = false
  local function release()
    if released then
      return
    end
    released = true
    asking = false
    if approval[client_id] == "pending" then
      approval[client_id] = nil
    end
  end
  Timer.After(PROMPT_TIMEOUT_MS, release)
  local shown = name and `"{name}"` or "A program that did not give its name"
  local ok, allowed = pcall(
    confirm,
    `{shown} wants to control this computer through Remote Control. Allow it?`
  )
  if released then
    return
  end
  if ok and allowed == nil then
    release()
    return
  end
  allowed = ok and allowed == true
  if allowed and name then
    remember(name)
  end
  if clients[client_id] and approval[client_id] == "pending" then
    approval[client_id] = allowed and "approved" or "denied"
  end
  released = true
  asking = false
end

-- nil to proceed, or (code, message) to refuse the command.
local function check_approval(client)
  local id = client.id
  local state = approval[id]
  if state == "approved" then
    return nil
  end
  if state == "denied" then
    return "not_approved",
      'this computer declined the connection; reconnect to be asked again, or turn off "Ask before a new app connects"'
  end
  local name = app_names[id]
  if remembered(name) then
    approval[id] = "approved"
    return nil
  end
  if state ~= "pending" and not asking then
    approval[id] = "pending"
    Run(function()
      ask(id, name)
    end)
  end
  return "awaiting_approval",
    `allow {name or "this program"} in the Remote Control prompt on this computer, then retry`
end

def("hello", function(client, req)
  local name = clean_name(req.app)
  -- a client that is already approved, pending or denied keeps its name, so
  -- the name remembered is the one the person saw.
  if name and approval[client.id] == nil then
    app_names[client.id] = name
  end
  -- a remembered name approves even a pending client; a denied one stays
  -- denied until it reconnects.
  if name and approval[client.id] == "pending" and remembered(name) then
    approval[client.id] = "approved"
  end
  return server_banner()
end, { open = true })

def("ping", function()
  return { pong = true, time_ms = System.Time() }
end, { open = true })

def("auth", function(client, req)
  if auth_token() == "" then
    authed[client.id] = true
    return { ok = true, note = "no token required" }
  end
  if token_ok(req.token) then
    authed[client.id] = true
    return { ok = true }
  end
  return nil, "bad_token", "token does not match"
end, { open = true })

def("commands", function()
  local names = {}
  for name in pairs(api) do
    names[#names + 1] = name
  end
  table.sort(names)
  return { commands = names, protocol = PROTOCOL_VERSION }
end)

--[[ hid — output

  down/up/combo/type/move/scroll are one-shot and fire directly. press and
  typewriter sleep between key events, so they must run inside a coroutine and
  are registered async (see the dispatcher's Run() path).

  mouse movement: HID.Move(dx, dy) is relative and single-shot; HID.MoveTo(x, y)
  lands on the point (exact in software mode, corrected on a Link in about
  150 ms; the hid.move_to command runs it blocking, so a following click lands
  after it). From relay 3.10.1, HID.MoveTo(x, y, ms) also glides over ms inside
  a coroutine; hid.move_smooth below keeps its own loop for older relays.
  Math.Spline / Math.Interpolate / Math.Resample smooth a macro action list, not
  the live cursor; they never call the transport.

  hid.move_smooth composes that motion for any relay: it steps along an eased
  path with HID.Move, sleeping between steps (so it needs a coroutine), and is
  exposed below as an async command taking { x, y, duration_ms?, steps? }.

  this output path works in both hardware (teensy) and software (SendInput /
  CGEvent) mode. what is hardware-only is suppressing or transforming the user's
  *incoming* movement (mouse_block / OnMove). in software mode a failed injection
  (uac secure desktop / uipi) latches gp_transport::injection_blocked, so a glide
  can silently no-op.
]]

def("hid.down", function(_, req)
  HID.Down(req.code)
end)
def("hid.up", function(_, req)
  HID.Up(req.code)
end)
def("hid.combo", function(_, req)
  HID.Combo(req.code)
end)
def("hid.type", function(_, req)
  HID.Type(req.text or "")
end)
def("hid.move", function(_, req)
  HID.Move(req.dx or 0, req.dy or 0)
end)
def("hid.move_to", function(_, req)
  HID.MoveTo(req.x or 0, req.y or 0)
end)
def("hid.scroll", function(_, req)
  HID.Scroll(req.delta or 0)
end)
def("hid.set_mouse_mode", function(_, req)
  HID.SetMouseMode(req.mode or "relative")
  return { mode = HID.GetMouseMode() }
end)
def("hid.get_mouse_mode", function()
  return { mode = HID.GetMouseMode() }
end)
def("hid.press", function(_, req)
  HID.Press(req.code, req.hold_ms or 20)
end, { async = true })
def("hid.typewriter", function(_, req)
  HID.Typewriter(req.text or "", req.delay_ms)
end, { async = true })
-- Glide to (x, y) over duration_ms across `steps` eased relative moves.
-- Async because Sleep needs a coroutine; replies with the final position.
def("hid.move_smooth", function(_, req)
  local tx, ty = req.x or 0, req.y or 0
  local steps = math.max(1, req.steps or 60)
  local duration_ms = req.duration_ms or 250
  local sx, sy = System.Mouse()
  local ax, ay = 0, 0 -- carry sub-pixel remainder so slow moves don't stall
  for i = 1, steps do
    local t = i / steps
    t = t < 0.5 and 2 * t * t or 1 - (-2 * t + 2) ^ 2 / 2 -- easeInOutQuad
    local wx, wy = sx + (tx - sx) * t, sy + (ty - sy) * t
    local cx, cy = System.Mouse() -- re-read: self-corrects vs ballistics/clamp
    local dx, dy = wx - cx + ax, wy - cy + ay
    local mdx, mdy = math.floor(dx), math.floor(dy)
    ax, ay = dx - mdx, dy - mdy
    if mdx ~= 0 or mdy ~= 0 then
      HID.Move(mdx, mdy)
    end
    Sleep(duration_ms / steps)
  end
  HID.MoveTo(tx, ty) -- snap out residual drift
  local fx, fy = System.Mouse()
  return { x = fx, y = fy }
end, { async = true })

--[[ hid: joypad

  needs a Rebind Link whose USB profile has a joypad; elsewhere the SDK raises
  and the reply is handler_error. hid.joypad takes JoypadState's fields flat
  (buttons, hat, x, y, z, rz, slider, dial) and ignores the rest of the
  request. the state is shared with every script and stays after a client
  disconnects, until hid.joypad_reset (which also drops what other scripts
  set) or until any script, this one included, stops or errors. pad buttons
  such as "PadA" also work in hid.down / hid.up / hid.press / hid.combo.
]]

def("hid.joypad", function(_, req)
  HID.Joypad(req)
end)
def("hid.axis", function(_, req)
  HID.Axis(req.name or "", req.value)
end)
def("hid.joypad_reset", function()
  HID.JoypadReset()
end)

--[[ system ]]

def("system.mouse", function()
  local x, y = System.Mouse()
  return { x = x, y = y }
end)
def("system.window", function()
  return { window = System.Window() }
end)
def("system.time", function()
  return { time_ms = System.Time() }
end)
def("system.screen", function()
  local w, h = System.Screen()
  return { width = w, height = h }
end)
def("system.exec", function(_, req)
  return System.Exec(req.cmd or "", { timeout = req.timeout, cwd = req.cwd })
end, { gate = "exec", async = true })
def("system.exec_detached", function(_, req)
  return {
    pid = System.ExecDetached(req.cmd or "", req.args, { cwd = req.cwd }),
  }
end, { gate = "exec" })
def("system.type_text", function(_, req)
  System.TypeText(req.text or "")
end)

--[[ screen ]]

-- screen reads keep the v1 error codes (screen_error / capture_error).
def("screen.pixel", function(_, req)
  local ok, hex = pcall(Screen.GetPixelColor, req.x, req.y)
  if not ok then
    return nil, "screen_error", tostring(hex)
  end
  local r, g, b = hex_rgb(hex)
  return { r = r, g = g, b = b }
end)
def("screen.pixel_hex", function(_, req)
  return { hex = Screen.GetPixelColor(req.x, req.y) }
end)
def("screen.resolution", function()
  local w, h = System.Screen()
  return { width = w, height = h }
end)
def("screen.displays", function()
  return { displays = Screen.List() }
end)
def("screen.list", function()
  return { displays = Screen.List() }
end)
-- capture just part of the desktop instead of a whole display: pass
-- region = { x, y, w, h } (display-local logical coords) and optionally
-- display (1-based index from screen.displays). omit both for the full display.
-- frames downscale to max_edge (default 1280) so a 4K capture stays well under
-- the script memory limit; pass max_edge = 0 for native resolution.
-- async: capture + encode run on an SDK worker thread and the handler yields
-- until the frame is ready, so captures never stall the relay tick thread.
def("screen.capture", function(_, req)
  local ok, res = pcall(Screen.Capture, {
    display = req.display,
    region = req.region,
    max_edge = req.max_edge or 1280,
  })
  if not ok then
    return nil, "capture_error", tostring(res)
  end
  return res
end, { async = true })
-- the window's rect in screen coords. Window.GetPos raises on macOS, where
-- the window list carries each rect instead, so the rect comes from the entry
-- with the same handle; the list leaves out windows under 50 points
local function windowRect(handle)
  local ok, pos = pcall(Window.GetPos, handle)
  if ok then
    return pos
  end
  for _, w in ipairs(Window.List()) do
    if w.handle == handle then
      return w
    end
  end
  return nil
end
-- convenience: capture exactly one window by handle or title. resolves the
-- window rect (screen coords), finds the display it sits on, and converts to a
-- display-local region clamped to that display. returns the same shape as
-- screen.capture ({ image, width, height, display, cursor }).
def("screen.capture_window", function(_, req)
  local handle = req.handle or Window.Find(req.title or "")
  local pos = handle and windowRect(handle)
  if not pos then
    return nil, "not_found", "no window matches"
  end
  local target = nil
  for _, d in ipairs(Screen.List()) do
    if
      pos.x >= d.x
      and pos.x < d.x + d.width
      and pos.y >= d.y
      and pos.y < d.y + d.height
    then
      target = d
      break
    end
    if d.primary then
      target = target or d
    end
  end
  if not target then
    return nil, "no_display", "no displays available"
  end
  local rx = math.max(0, pos.x - target.x)
  local ry = math.max(0, pos.y - target.y)
  local rw = math.min(pos.width, target.width - rx)
  local rh = math.min(pos.height, target.height - ry)
  return Screen.Capture({
    display = target.index,
    region = { x = rx, y = ry, w = rw, h = rh },
    max_edge = req.max_edge or 1280,
  })
end, { async = true })
def("screen.search_color", function(_, req)
  local hit = Screen.SearchForColor(req.region, req.color, req.tolerance)
  return { match = hit }
end, { async = true })
-- needle is a PNG path relative to the script directory.
def("screen.find_image", function(_, req)
  return {
    match = Screen.FindImage(req.needle or "", {
      display = req.display,
      region = req.region,
      tolerance = req.tolerance,
      scales = req.scales,
    }),
  }
end, { async = true })
-- windows only.
def("screen.work_area", function(_, req)
  return { area = Screen.WorkArea(req.x, req.y) }
end)

--[[ input — state ]]

def("input.keys", function()
  return { keys = Input.GetActiveKeys() }
end)
def("input.is_down", function(_, req)
  return { down = Input.IsDown(req.code) }
end)
def("input.duration", function(_, req)
  return { ms = Input.GetDuration(req.code) }
end)
def("input.modifiers", function()
  return { modifiers = Input.GetModifiers() }
end)
def("input.mouse_pos", function()
  return { pos = Input.GetMousePos() }
end)
def("input.axis", function(_, req)
  return { value = Input.GetAxis(req.name or "") }
end)

--[[ clipboard ]]

def("clipboard.get", function()
  return { text = Clipboard.Get() or "" }
end)
def("clipboard.set", function(_, req)
  Clipboard.Set(req.text or "")
end)

--[[ window ]]

def("window.list", function(_, req)
  return { windows = Window.List(req.filter) }
end)
def("window.find", function(_, req)
  return { handle = Window.Find(req.title or "") }
end)
def("window.get_title", function(_, req)
  return { title = Window.GetTitle(req.handle) }
end)
def("window.get_class", function(_, req)
  return { class = Window.GetClass(req.handle) }
end)
def("window.get_pos", function(_, req)
  return { pos = Window.GetPos(req.handle) }
end)
def("window.get_pid", function(_, req)
  return { pid = Window.GetPID(req.handle) }
end)
def("window.is_visible", function(_, req)
  return { visible = Window.IsVisible(req.handle) }
end)
def("window.is_active", function(_, req)
  return { active = Window.IsActive(req.handle) }
end)
-- get_foreground / from_point: windows and macos. get_state: windows only.
def("window.get_foreground", function()
  return { handle = Window.GetForeground() }
end)
def("window.from_point", function(_, req)
  return { handle = Window.FromPoint(req.x or 0, req.y or 0) }
end)
def("window.get_state", function(_, req)
  return { state = Window.GetState(req.handle) }
end)
def("window.activate", function(_, req)
  Window.Activate(req.handle)
end)
def("window.move", function(_, req)
  Window.Move(req.handle, req.x, req.y, req.width, req.height)
end)
def("window.minimize", function(_, req)
  Window.Minimize(req.handle)
end)
def("window.maximize", function(_, req)
  Window.Maximize(req.handle)
end)
def("window.restore", function(_, req)
  Window.Restore(req.handle)
end)
def("window.hide", function(_, req)
  Window.Hide(req.handle)
end)
def("window.show", function(_, req)
  Window.Show(req.handle)
end)
def("window.set_title", function(_, req)
  Window.SetTitle(req.handle, req.title or "")
end)
def("window.set_always_on_top", function(_, req)
  Window.SetAlwaysOnTop(req.handle, req.enabled and true or false)
end)
def("window.set_transparency", function(_, req)
  Window.SetTransparency(req.handle, req.alpha or 255)
end)
def("window.close", function(_, req)
  Window.Close(req.handle)
end)
def("window.kill", function(_, req)
  Window.Kill(req.handle)
end)
-- wait/wait_active/wait_close block until the condition (or timeout), so async.
def("window.wait", function(_, req)
  return { handle = Window.Wait(req.title or "", req.timeout) }
end, { async = true })
def("window.wait_active", function(_, req)
  return { activated = Window.WaitActive(req.title or "", req.timeout) }
end, { async = true })
def("window.wait_close", function(_, req)
  return { closed = Window.WaitClose(req.title or "", req.timeout) }
end, { async = true })

--[[ app ]]

def("app.front", function()
  return { app = App.Front() }
end)
def("app.is_front", function(_, req)
  return { result = App.IsFront(req.name or "") }
end)
def("app.is_running", function(_, req)
  return { result = App.IsRunning(req.name or "") }
end)
def("app.is_hidden", function(_, req)
  return { result = App.IsHidden(req.name or "") }
end)
def("app.activate", function(_, req)
  App.Activate(req.name or "")
end)
def("app.hide", function(_, req)
  App.Hide(req.name or "")
end)
def("app.quit", function(_, req)
  App.Quit(req.name or "")
end)

--[[ process ]]

def("process.exists", function(_, req)
  return { pid = Process.Exists(req.name or "") }
end)
def("process.list", function(_, req)
  return { processes = Process.List(req.name) }
end)
def("process.kill", function(_, req)
  return { killed = Process.Kill(req.pid) }
end, { gate = "exec" })

--[[ env ]]

def("env.get", function(_, req)
  return { value = Env.Get(req.name or "") }
end)
def("env.set", function(_, req)
  Env.Set(req.name or "", req.value or "")
end, { gate = "exec" })
-- known-folder getters: home/config/data/desktop/documents/downloads/appdata/temp.
for _, dir in ipairs({
  "Home",
  "Config",
  "Data",
  "Desktop",
  "Documents",
  "Downloads",
  "AppData",
  "Temp",
}) do
  def("env." .. dir:lower(), function()
    return { path = Env[dir]() }
  end)
end

--[[ hash & codec ]]

for _, algo in ipairs({ "MD5", "SHA1", "SHA256", "SHA512", "CRC32" }) do
  def("hash." .. algo:lower(), function(_, req)
    return { digest = Hash[algo](req.data or "") }
  end)
end
def("hash.hmac", function(_, req)
  return {
    digest = Hash.HMAC(req.algo or "sha256", req.key or "", req.data or ""),
  }
end)
def("codec.base64", function(_, req)
  return { result = Codec.Base64(req.data or "") }
end)
def("codec.base64_decode", function(_, req)
  return { result = Codec.Base64Decode(req.data or "") }
end)
def("codec.hex", function(_, req)
  return { result = Codec.Hex(req.data or "") }
end)
def("codec.hex_decode", function(_, req)
  return { result = Codec.HexDecode(req.data or "") }
end)

--[[ regex ]]

def("regex.is_match", function(_, req)
  return { result = Regex.IsMatch(req.text or "", req.pattern or "") }
end)
def("regex.find", function(_, req)
  return { match = Regex.Find(req.text or "", req.pattern or "") }
end)
def("regex.find_all", function(_, req)
  return { matches = Regex.FindAll(req.text or "", req.pattern or "") }
end)
def("regex.replace", function(_, req)
  return {
    result = Regex.Replace(req.text or "", req.pattern or "", req.rep or ""),
  }
end)
def("regex.replace_all", function(_, req)
  return {
    result = Regex.ReplaceAll(req.text or "", req.pattern or "", req.rep or ""),
  }
end)
def("regex.split", function(_, req)
  return { parts = Regex.Split(req.text or "", req.pattern or "") }
end)

--[[ config — toml ]]

def("config.parse_toml", function(_, req)
  return { table = Config.ParseTOML(req.text or "") }
end)
def("config.to_toml", function(_, req)
  return { text = Config.ToTOML(req.table or {}) }
end)
def("config.read_toml", function(_, req)
  return { table = Config.ReadTOML(req.path or "") }
end, { gate = "file" })
def("config.write_toml", function(_, req)
  Config.WriteTOML(req.path or "", req.table or {})
end, { gate = "file" })

--[[ file: sandboxed to the script directory, gated behind allow_file ]]

def("file.read", function(_, req)
  return { content = File.Read(req.path or "") }
end, { gate = "file" })
def("file.read_bytes", function(_, req)
  return { bytes = File.ReadBytes(req.path or "") }
end, { gate = "file" })
def("file.write", function(_, req)
  File.Write(req.path or "", req.content or "")
end, { gate = "file" })
def("file.append", function(_, req)
  File.Append(req.path or "", req.content or "")
end, { gate = "file" })
def("file.exists", function(_, req)
  return { exists = File.Exists(req.path or "") }
end, { gate = "file" })
def("file.delete", function(_, req)
  return { deleted = File.Delete(req.path or "") }
end, { gate = "file" })
def("file.list", function(_, req)
  return { entries = File.List(req.path) }
end, { gate = "file" })
def("file.mkdir", function(_, req)
  return { created = File.MkDir(req.path or "") }
end, { gate = "file" })
def("file.rmdir", function(_, req)
  File.RmDir(req.path or "")
end, { gate = "file" })
def("file.is_dir", function(_, req)
  return { result = File.IsDir(req.path or "") }
end, { gate = "file" })
def("file.is_file", function(_, req)
  return { result = File.IsFile(req.path or "") }
end, { gate = "file" })
def("file.size", function(_, req)
  return { bytes = File.GetSize(req.path or "") }
end, { gate = "file" })
def("file.time", function(_, req)
  return { mtime = File.GetTime(req.path or "") }
end, { gate = "file" })
def("file.copy", function(_, req)
  File.Copy(req.src or "", req.dst or "")
end, { gate = "file" })
def("file.move", function(_, req)
  File.Move(req.src or "", req.dst or "")
end, { gate = "file" })
def("file.read_json", function(_, req)
  return { value = File.ReadJSON(req.path or "") }
end, { gate = "file" })
def("file.write_json", function(_, req)
  File.WriteJSON(req.path or "", req.value or {})
end, { gate = "file" })
def("file.script_dir", function()
  return { path = File.GetScriptDir() }
end, { gate = "file" })

--[[ net — outbound http; yields, so async ]]

-- the wire is JSON text, so a body that is not UTF-8 travels as body_base64
local function wire(response)
  if response.body and utf8.len(response.body) == nil then
    response.body_base64 = Codec.Base64(response.body)
    response.body = nil
  end
  return { response = response }
end

def("net.get", function(_, req)
  return wire(Net.Get(req.url or "", req.headers, { timeout = req.timeout }))
end, { async = true })
def("net.post", function(_, req)
  return wire(
    Net.Post(
      req.url or "",
      req.body or "",
      req.headers,
      { timeout = req.timeout }
    )
  )
end, { async = true })
def("net.put", function(_, req)
  return wire(
    Net.Put(
      req.url or "",
      req.body or "",
      req.headers,
      { timeout = req.timeout }
    )
  )
end, { async = true })
def("net.patch", function(_, req)
  return wire(
    Net.Patch(
      req.url or "",
      req.body or "",
      req.headers,
      { timeout = req.timeout }
    )
  )
end, { async = true })
def("net.delete", function(_, req)
  return wire(Net.Delete(req.url or "", req.headers, { timeout = req.timeout }))
end, { async = true })
def("net.head", function(_, req)
  return wire(Net.Head(req.url or "", req.headers, { timeout = req.timeout }))
end, { async = true })
def("net.request", function(_, req)
  return wire(Net.Request(req.options or {}))
end, { async = true })

--[[ math — rng + macro transforms ]]

def("math.random", function(_, req)
  return { value = Math.Random(req.min or 0, req.max or 1) }
end)
def("math.gaussian", function(_, req)
  return { value = Math.Gaussian(req.mean or 0, req.std_dev or 1) }
end)
def("math.scale", function(_, req)
  return {
    macro = Math.Scale(req.macro or {}, req.x_factor or 1, req.y_factor or 1),
  }
end)
def("math.spline", function(_, req)
  return { macro = Math.Spline(req.macro or {}, req.tension or 0.5) }
end)
def("math.resample", function(_, req)
  return { macro = Math.Resample(req.macro or {}, req.interval_ms or 10) }
end)
def("math.interpolate", function(_, req)
  return {
    macro = Math.Interpolate(req.macro or {}, req.interval_ms or 10, req.mode),
  }
end)
def("math.time_comp", function(_, req)
  return { macro = Math.TimeComp(req.macro or {}, req.target_ms or 1000) }
end)

--[[ macro ]]

def("macro.record", function(_, req)
  Macro.Record(req.options)
end)
def("macro.finish", function()
  return { macro = Macro.Finish() }
end)
def("macro.play", function(_, req)
  Macro.Play(req.macro or {}, req.speed, req.mode)
end)
def("macro.stream", function(_, req)
  Macro.Stream(req.macro or {})
end)
def("macro.abort", function()
  Macro.Abort()
end)
def("macro.stop_all", function()
  Macro.StopAll()
end)

--[[ audio

  Audio.Play returns a sound handle. we keep it in a server-side registry and
  hand the client an integer id, so stop/pause/resume/volume can address a
  specific sound by id over the wire.
]]

def("audio.beep", function()
  Audio.Beep()
end)
def("audio.play", function(_, req)
  local handle = Audio.Play(req.path or "", req.options)
  local id = alloc_id()
  sounds[id] = handle
  return { sound = id }
end)
def("audio.stop", function(_, req)
  local h = sounds[req.sound]
  if h then
    h:Stop()
    sounds[req.sound] = nil
  end
end)
def("audio.pause", function(_, req)
  local h = sounds[req.sound]
  if h then
    h:Pause()
  end
end)
def("audio.resume", function(_, req)
  local h = sounds[req.sound]
  if h then
    h:Resume()
  end
end)
def("audio.is_playing", function(_, req)
  local h = sounds[req.sound]
  return { playing = h ~= nil and h:IsPlaying() or false }
end)
def("audio.set_volume", function(_, req)
  local h = sounds[req.sound]
  if h then
    h:SetVolume(req.volume or 1.0)
  end
end)
def("audio.get_volume", function(_, req)
  local h = sounds[req.sound]
  return { volume = h and h:GetVolume() or 0 }
end)
def("audio.stop_all", function()
  Audio.StopAll()
  sounds = {}
end)
def("audio.set_master_volume", function(_, req)
  Audio.SetMasterVolume(req.volume or 1.0)
end)
def("audio.get_master_volume", function()
  return { volume = Audio.GetMasterVolume() }
end)

--[[ timer

  server-side timers whose callback fires a push { t="timer", handle } to the
  creating client. handles are integer ids in a per-client registry, cancelled
  automatically when the client disconnects.
]]

def("timer.after", function(client, req)
  local id = alloc_id()
  local cid = client.id
  local handle = Timer.After(req.ms or 1000, function()
    local c = clients[cid]
    if c then
      c:Send(encode({ t = "timer", handle = id, kind = "after" }))
    end
    if timers[cid] then
      timers[cid][id] = nil
    end
  end)
  timers[cid] = timers[cid] or {}
  timers[cid][id] = handle
  return { handle = id }
end)
def("timer.every", function(client, req)
  local id = alloc_id()
  local cid = client.id
  local handle = Timer.Every(req.ms or 1000, function()
    local c = clients[cid]
    if c then
      c:Send(encode({ t = "timer", handle = id, kind = "every" }))
    end
  end)
  timers[cid] = timers[cid] or {}
  timers[cid][id] = handle
  return { handle = id }
end)
def("timer.cancel", function(client, req)
  local set = timers[client.id]
  local handle = set and set[req.handle]
  if handle then
    handle:Cancel()
    set[req.handle] = nil
  end
end)
def("timer.cancel_all", function(client)
  local set = timers[client.id]
  if set then
    for _, handle in pairs(set) do
      handle:Cancel()
    end
    timers[client.id] = {}
  end
end)

--[[ log ]]

for _, level in ipairs({ "Info", "Warn", "Error", "Debug" }) do
  def("log." .. level:lower(), function(_, req)
    Log[level](req.message or "")
  end)
end

--[[ ui — this script's own config panel; read + notify only ]]

def("ui.notify", function(_, req)
  UI.Notify(req.message or "", req.variant or "info")
end)
-- the field name travels in `key`; `id` is the request's correlation id.
def("ui.get", function(_, req)
  if REDACTED[req.key] then
    return nil, "redacted", "that value is not readable over the wire"
  end
  return { value = UI.Get(req.key or "") }
end)
def("ui.get_all", function()
  local all = UI.GetAll()
  for k in pairs(REDACTED) do
    all[k] = nil
  end
  return { values = all }
end)
def("ui.schema", function()
  return { schema = UI.GetSchema() }
end)

--[[ registry — windows only; reads open, writes gated ]]

if Registry then
  def("registry.read", function(_, req)
    return { value = Registry.Read(req.key or "", req.name or "") }
  end)
  def("registry.write", function(_, req)
    Registry.Write(
      req.key or "",
      req.type or "string",
      req.name or "",
      req.value
    )
  end, { gate = "registry" })
  def("registry.delete_value", function(_, req)
    Registry.DeleteValue(req.key or "", req.name or "")
  end, { gate = "registry" })
  def("registry.delete_key", function(_, req)
    Registry.DeleteKey(req.key or "")
  end, { gate = "registry" })
  def("registry.create_key", function(_, req)
    Registry.CreateKey(req.key or "")
  end, { gate = "registry" })
end

--[[ script self-control ]]

def("script.reload", function()
  Script.Reload()
end)
def("script.exit", function(_, req)
  Script.Exit(req.reason)
end)

--[[ subscriptions ]]

def("subscribe", function(client, req)
  local events = req.events or {}
  -- stash the live client handle so OnTick / timer callbacks can :Send() to it
  -- without a round-trip. dead clients are cleaned up in OnClose.
  subscribers[client.id] = subscribers[client.id] or { client = client }
  subscribers[client.id].client = client
  for _, name in ipairs(events) do
    subscribers[client.id][name] = true
  end
  return { ok = true, subscribed = events }
end)
def("unsubscribe", function(client, req)
  local subs = subscribers[client.id]
  if subs then
    for _, name in ipairs(req.events or {}) do
      subs[name] = nil
    end
  end
  return { ok = true }
end)

--[[ observe: rb.observe recordings, gated; see observe.luau ]]

local observe = require("observe")
local function connected(client_id)
  return clients[client_id] ~= nil
end
for name, spec in pairs(observe.commands(connected)) do
  def(name, spec.fn, { async = spec.async, gate = "observe" })
end

--[[ escape hatch — arbitrary lua in the sandbox; gated ]]

def("lua.exec", function(_, req)
  if type(loadstring) ~= "function" then
    return nil, "unavailable", "loadstring is not available in this sandbox"
  end
  local source = req.source or ""
  local fn, parse_err = loadstring(source)
  if not fn then
    return nil, "parse_error", tostring(parse_err)
  end
  local ok, result = pcall(fn)
  if ok then
    return { result = result }
  end
  -- echo the failing line so a remote caller (human or agent) can see WHAT
  -- failed, not just why: pcall errors carry "[string ...]:<line>:" — quote
  -- that source line back. Without it, agents blindly resend the same script.
  local msg = tostring(result)
  local line = msg:match(":(%d+):")
  if line then
    local n, excerpt = tonumber(line), nil
    local i = 0
    for l in (source .. "\n"):gmatch("(.-)\n") do
      i = i + 1
      if i == n then
        excerpt = l
        break
      end
    end
    if excerpt then
      msg = msg .. " | line " .. line .. ": " .. excerpt:sub(1, 160)
    end
  end
  return nil, "runtime_error", msg
end, { gate = "lua", async = true })

--[[ dispatch

  one place enforces auth, gating, async wrapping, error shaping, and replying.
  async handlers run in a coroutine (Run()) so yielding sdk calls can complete
  before replying; everything else runs inline under a pcall.
]]

local function invoke(client, req, spec)
  local res, code, msg = spec.fn(client, req)
  if code then
    err(client, req, code, msg or code)
  else
    reply(client, req, res or { ok = true })
  end
end

local function dispatch(client, req)
  local spec = api[req.t or ""]
  if not spec then
    err(client, req, "unknown_command", `unknown command '{tostring(req.t)}'`)
    return
  end

  if auth_token() ~= "" and not spec.open and not authed[client.id] then
    err(
      client,
      req,
      "unauthenticated",
      'send { t = "auth", token = "..." } first'
    )
    return
  end

  local gate = GATES[spec.gate]
  if gate and cfg[gate.key] ~= true then
    err(
      client,
      req,
      "disabled",
      `'{req.t}' is off: turn on "{gate.label}" in Remote Control's Options`
    )
    return
  end

  if not spec.open and cfg.ask_new_apps then
    local code, msg = check_approval(client)
    if code then
      err(client, req, code, msg)
      return
    end
  end

  if spec.async then
    Run(function()
      local ok, res, code, msg = pcall(spec.fn, client, req)
      if not ok then
        err(client, req, "handler_error", tostring(res))
      elseif code then
        err(client, req, code, msg or code)
      else
        reply(client, req, res or { ok = true })
      end
    end)
    return
  end

  local ok, e = pcall(invoke, client, req, spec)
  if not ok then
    err(client, req, "handler_error", tostring(e))
  end
end

--[[ websocket server lifecycle ]]

function OnStart()
  local host = cfg.local_only and "127.0.0.1" or "0.0.0.0"
  server = Net.WSListen(cfg.port, {
    OnConnect = function(client)
      Log.Info(`Remote: client {client.id} connected`)
      clients[client.id] = client
      local banner = server_banner()
      banner.t = "hello"
      send(client, banner)
    end,

    OnMessage = function(client, payload, is_binary)
      if is_binary then
        err(
          client,
          { id = nil },
          "no_binary",
          "binary frames are not supported"
        )
        return
      end
      local ok, req = pcall(JSON.Parse, payload)
      if not ok or type(req) ~= "table" then
        err(client, { id = nil }, "bad_json", "could not parse JSON object")
        return
      end
      dispatch(client, req)
    end,

    OnClose = function(client)
      Log.Info(`Remote: client {client.id} disconnected`)
      local set = timers[client.id]
      if set then
        for _, handle in pairs(set) do
          handle:Cancel()
        end
      end
      timers[client.id] = nil
      subscribers[client.id] = nil
      authed[client.id] = nil
      clients[client.id] = nil
      app_names[client.id] = nil
      approval[client.id] = nil
      observe.closed(client.id)
    end,
  }, { host = host })

  Log.Info(
    `Remote server listening on ws://{host}:{cfg.port} (auth={auth_token() ~= "" and "required" or "open"})`
  )
  UI.Notify(`Remote: ws://{host}:{cfg.port}`, "success")
end

function OnStop()
  if server then
    server:Stop()
    server = nil
  end
  for _, set in pairs(timers) do
    for _, handle in pairs(set) do
      handle:Cancel()
    end
  end
  Audio.StopAll()
  clients = {}
  subscribers = {}
  authed = {}
  timers = {}
  sounds = {}
  app_names = {}
  approval = {}
  asking = false
end

--[[ subscription streams

  push events from OnTick. each stream is sent only to clients subscribed to it,
  and mouse/window only when the underlying state has changed.
]]

function OnTick()
  if not server or next(subscribers) == nil then
    return
  end

  local mouse_msg, window_msg, input_msg = nil, nil, nil

  local x, y = System.Mouse()
  if x ~= last_mouse_x or y ~= last_mouse_y then
    last_mouse_x, last_mouse_y = x, y
    mouse_msg = encode({ t = "mouse", x = x, y = y })
  end

  local win = System.Window()
  if win.title ~= last_window_title then
    last_window_title = win.title
    window_msg = encode({ t = "window", window = win })
  end

  local any_input = false
  for _, subs in pairs(subscribers) do
    if subs.input then
      any_input = true
      break
    end
  end
  if any_input then
    input_msg = encode({
      t = "input",
      keys = Input.GetActiveKeys(),
      modifiers = Input.GetModifiers(),
    })
  end

  -- fan out: a closed client's :Send() is a silent no-op, so no cleanup here;
  -- OnClose removes the table entry.
  for _, subs in pairs(subscribers) do
    if mouse_msg and subs.mouse then
      subs.client:Send(mouse_msg)
    end
    if window_msg and subs.window then
      subs.client:Send(window_msg)
    end
    if input_msg and subs.input then
      subs.client:Send(input_msg)
    end
  end
end