--[[ rebind: min_sdk=3.0.0 rebind: name=Remote rebind: version=2.1.0 rebind: author=Rebind rebind: description=JSON-RPC WebSocket server exposing the full Rebind SDK — HID, System, Screen, Window, Input, Clipboard, App, Process, Env, Hash, Codec, Regex, Config, File, Net, Dialog, Math, Macro, Audio, Timer, Registry, UI — for remote control. rebind: instance=single rebind: tick_rate=1000 rebind: permission=net rebind: permission=exec rebind: memory_limit_mb=64 --]] --[[ 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. canonical copy: packages/lua-sdk/examples/remote.luau. mirrored to packages/client-ts/lua-server/ (so the npm package ships a matching reference server) and www/docs-v2/src/develop/. keep the three in sync when editing, and bump PROTOCOL_VERSION in all of them on a breaking change. --]] --[[ user config danger gates stay file constants, not UI fields: they are the dangerous switches, so auditing them by reading the script (grep ALLOW_) is the point, and nothing over the wire can flip them. ALLOW_EXEC System.Exec / System.ExecDetached / Process.Kill. ALLOW_FILE File.* and Config.ReadTOML/WriteTOML (sandboxed to the script directory, but still disabled by default remotely). ALLOW_REGISTRY Registry writes/deletes (windows). reads stay open. ALLOW_LUA_EXEC lua.exec — arbitrary lua in the sandbox. highest risk. the auth token IS a schema field (the port panel), because a secret is easier to rotate from the ui than by editing + reinstalling. it 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. SECURITY: Net.WSListen always binds 0.0.0.0 — the server is reachable from the lan, not just localhost. there is no script-side way to restrict it. set a token whenever the machine is on an untrusted network; "open" is localhost-safe only if you trust everything that can route to this port. --]] local ALLOW_EXEC = false local ALLOW_FILE = false local ALLOW_REGISTRY = false local ALLOW_LUA_EXEC = false local PROTOCOL_VERSION = "1.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 = open (lan-exposed!)", tooltip = 'when set, clients must send { t="auth", token="..." } first. redacted from ui.get.', }), }) local GATES = { exec = ALLOW_EXEC, file = ALLOW_FILE, registry = ALLOW_REGISTRY, lua = ALLOW_LUA_EXEC, } -- 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() return { protocol = PROTOCOL_VERSION, auth_required = auth_token() ~= "", gates = { exec = ALLOW_EXEC, file = ALLOW_FILE, registry = ALLOW_REGISTRY, lua = ALLOW_LUA_EXEC }, } 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. --]] local server = nil local clients = {} local authed = {} local subscribers = {} local timers = {} local sounds = {} local next_id = 0 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.*, Dialog.*, Window.Wait*, 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 ]] def("hello", function() 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 — read this before adding a "smooth move" command. the sdk has no built-in smooth / glide / tween. there are only two movement primitives, and both are instantaneous: HID.Move(dx, dy) relative, single shot. emits one mouse_move of (dx, dy). HID.MoveTo(x, y) absolute, but it teleports: it reads the current cursor, computes the delta, and fires it in one move. it runs up to 3 correction iterations only when the mouse mode is "absolute" (HID.SetMouseMode); in the default "relative" mode it is a single jump. either way: no easing, no intermediate steps, no visible travel. Math.Spline / Math.Interpolate / Math.Resample look relevant but are not: they smooth a *macro action list* (recorded path data), they do not drive the live cursor. they transform data; they never call the transport. so smooth motion is something you compose, not a function you call. step along an eased path with HID.Move, sleeping between steps. Sleep needs a coroutine, so any smooth-move handler must be async (run in Run()) — see hid.press below for the pattern. reference idiom (easeInOutQuad, self-correcting, sub-pixel carry, snap-out at the end): local function move_smooth(tx, ty, duration_ms, steps) steps = steps or 60 local sx, sy = System.Mouse() local ax, ay = 0, 0 -- carry sub-pixel remainder 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 -- keep the fraction for next step if mdx ~= 0 or mdy ~= 0 then HID.Move(mdx, mdy) end Sleep(duration_ms / steps) end HID.MoveTo(tx, ty) -- kill residual drift end why each part matters: - re-reading System.Mouse() every step corrects against pointer ballistics (mouse acceleration / epp) and edge clamping — the cursor may not land where a blind sum of deltas would predict. - the sub-pixel remainder (ax/ay) keeps slow moves from stalling: without it a per-step delta < 1px floors to 0 forever and the cursor never moves. - the trailing MoveTo snaps out any accumulated error so the final position is exact. for human-looking paths, swap the easing line for Math.Spline over waypoints and add Math.Gaussian jitter. this idiom is exposed over the wire below as the async `hid.move_smooth` command taking { x, y, duration_ms?, steps? }, so one call does the whole glide instead of the client streaming dozens of hid.move frames. this output path works in both hardware (teensy) and software (SendInput / CGEvent) mode — injecting movement is fine either way. what is hardware-only is suppressing or transforming the user's *incoming* movement (mouse_block / OnMove): in software mode the os draws the cursor upstream of the hooks, so a script can observe it but not intercept it (see the relay readme). also: in software mode a failed injection (uac secure desktop / uipi) latches gp_transport::injection_blocked, so a glide can silently no-op there — the trailing MoveTo self-correct is what recovers the final position. --]] 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 the cursor to (x, y) over duration_ms across `steps` eased relative -- moves — the move_smooth idiom from the block comment above. async (Sleep needs -- a coroutine). replies with the final landed 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 }) --[[ 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" }) def("system.exec_detached", function(_, req) return { pid = System.ExecDetached(req.cmd or "", req.args, { cwd = req.cwd }) } end, { gate = "exec" }) --[[ screen ]] def("screen.pixel", function(_, req) local r, g, b = hex_rgb(Screen.GetPixelColor(req.x, req.y)) 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. def("screen.capture", function(_, req) return Screen.Capture({ display = req.display, region = req.region, max_edge = req.max_edge or 1280, }) 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 "") if not handle then return nil, "not_found", "no window matches" end local pos = Window.GetPos(handle) -- { x, y, width, height } in screen coords 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) def("screen.search_color", function(_, req) local hit = Screen.SearchForColor(req.region, req.color, req.tolerance) return { match = hit } 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) --[[ 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) 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) -- 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 ]] def("net.get", function(_, req) return { response = Net.Get(req.url or "", req.headers, { timeout = req.timeout }) } end, { async = true }) def("net.post", function(_, req) return { response = Net.Post(req.url or "", req.body or "", req.headers, { timeout = req.timeout }) } end, { async = true }) def("net.put", function(_, req) return { response = Net.Put(req.url or "", req.body or "", req.headers, { timeout = req.timeout }) } end, { async = true }) def("net.patch", function(_, req) return { response = Net.Patch(req.url or "", req.body or "", req.headers, { timeout = req.timeout }) } end, { async = true }) def("net.delete", function(_, req) return { response = Net.Delete(req.url or "", req.headers, { timeout = req.timeout }) } end, { async = true }) def("net.head", function(_, req) return { response = Net.Head(req.url or "", req.headers, { timeout = req.timeout }) } end, { async = true }) def("net.request", function(_, req) return { response = Net.Request(req.options or {}) } end, { async = true }) --[[ dialog — native os dialogs; yield until dismissed ]] def("dialog.message", function(_, req) Dialog.Message(req.text or "", req.options) end, { async = true }) def("dialog.confirm", function(_, req) return { confirmed = Dialog.Confirm(req.text or "", req.options) } end, { async = true }) def("dialog.open_file", function(_, req) return { path = Dialog.OpenFile(req.options) } end, { async = true }) def("dialog.save_file", function(_, req) return { path = Dialog.SaveFile(req.options) } end, { async = true }) def("dialog.open_dir", function(_, req) return { path = Dialog.OpenDir(req.options) } 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) def("ui.get", function(_, req) if REDACTED[req.id] then return nil, "redacted", "that value is not readable over the wire" end return { value = UI.Get(req.id 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) --[[ 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 fn, parse_err = loadstring(req.source or "") if not fn then return nil, "parse_error", tostring(parse_err) end local ok, result = pcall(fn) if ok then return { result = result } end return nil, "runtime_error", tostring(result) end, { gate = "lua" }) --[[ 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 if spec.gate and not GATES[spec.gate] then err(client, req, "disabled", `'{req.t}' is disabled in config (ALLOW_{spec.gate:upper()})`) return 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() 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 end, }) Log.Info( `Remote server listening on ws://0.0.0.0:{cfg.port} (auth={auth_token() ~= "" and "required" or "open"})` ) UI.Notify(`Remote: ws://0.0.0.0:{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 = {} 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