Pocket Deck

@itzchillv2.1.0

Use your phone as a trackpad, media remote, and clipboard bridge.

Files

Version 2.1.0 (current)

main.luau

--[[
  rebind: min_sdk=3.9.0
  rebind: permissions=net,exec
  rebind: tick_rate=10
  rebind: memory_limit_mb=24
]]

local HTTP_PORT = 19880
local WS_PORT = 19881
local MARKETPLACE_LINK = "rebind://marketplace"
local PHONE_DIR = "phone"
local SERVER_SCRIPT = "phone/server.ps1"
local PID_FILE = "http.pid"

local cfg = rb.ui.schema({
  auth_token = rb.ui.text("", {
    label = "Phone access token",
    placeholder = "generated automatically if blank",
    tooltip = "the phone must enter this before it can control this PC",
    group = "Security",
  }),
  show_start_toast = rb.ui.toggle(true, {
    label = "Show startup notice",
    group = "Behavior",
  }),
})

local state = {
  ws = nil,
  http_pid = nil,
  authed = {},
  mouse_held = {},
}

local function trim(value)
  return (tostring(value or ""):gsub("^%s+", ""):gsub("%s+$", ""))
end

local function token()
  return trim(cfg.auth_token)
end

local function token_ok(given)
  local expected = token()
  if expected == "" then
    return true
  end
  return rb.hash.sha256(tostring(given or "")) == rb.hash.sha256(expected)
end

local function make_token()
  local seed = tostring(rb.system.time()) .. tostring(math.random()) .. tostring({})
  return rb.hash.sha256(seed):sub(1, 16):lower()
end

local function notify(message)
  pcall(rb.overlay.toast, message, {
    title = "Pocket Deck",
    duration = 3200,
  })
end

local function send(client, payload)
  local ok, text = pcall(rb.json.stringify, payload)
  if not ok then
    return
  end
  pcall(function()
    client:send(text)
  end)
end

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

local function fail(client, request, code, message)
  reply(client, request, {
    error = {
      code = code,
      message = message,
    },
  })
end

local function media_key(action)
  local keys = {
    -- The phone sends Rebind key names. Keep short aliases for any older
    -- cached page that used the original action names.
    MediaPlay = "MediaPlay",
    MediaNext = "MediaNext",
    MediaPrev = "MediaPrev",
    VolumeMute = "VolumeMute",
    VolumeUp = "VolumeUp",
    VolumeDown = "VolumeDown",
    play = "MediaPlay",
    next = "MediaNext",
    prev = "MediaPrev",
    mute = "VolumeMute",
    up = "VolumeUp",
    down = "VolumeDown",
  }
  return keys[tostring(action or "")]
end

local function handle(client, request)
  local kind = tostring(request.t or "")

  if kind == "auth" then
    if token_ok(request.token) then
      state.authed[client.id] = true
      reply(client, request, { ok = true })
    else
      fail(client, request, "bad_token", "wrong access token")
    end
    return
  end

  if not state.authed[client.id] then
    fail(client, request, "unauthenticated", "send auth first")
    return
  end

  if kind == "ping" then
    reply(client, request, { ok = true })
    return
  end

  if kind == "marketplace" then
    local ok, err = pcall(rb.system.exec_detached, "cmd.exe", {
      args = { "/c", "start", "", MARKETPLACE_LINK },
    })
    if not ok then
      rb.log.warn("Pocket Deck marketplace link: " .. tostring(err))
      fail(client, request, "marketplace_failed", "could not open Rebind Marketplace")
      return
    end
    reply(client, request, { ok = true })
    return
  end

  if kind == "move" then
    local dx = math.max(-2000, math.min(2000, math.floor(tonumber(request.dx) or 0)))
    local dy = math.max(-2000, math.min(2000, math.floor(tonumber(request.dy) or 0)))
    if dx ~= 0 or dy ~= 0 then
      pcall(rb.hid.move, dx, dy)
    end
    reply(client, request, { ok = true })
    return
  end

  if kind == "click" then
    local buttons = { left = "Mouse1", right = "Mouse2", middle = "Mouse3" }
    local key = buttons[tostring(request.button or "left")]
    if not key then
      fail(client, request, "bad_button", "button must be left, right, or middle")
      return
    end
    pcall(rb.hid.combo, key)
    reply(client, request, { ok = true })
    return
  end

  if kind == "mouse" then
    local buttons = { left = "Mouse1", right = "Mouse2", middle = "Mouse3" }
    local key = buttons[tostring(request.button or "left")]
    local action = tostring(request.action or "")
    if not key then
      fail(client, request, "bad_button", "button must be left, right, or middle")
      return
    end
    if action == "down" then
      if pcall(rb.hid.down, key) then
        state.mouse_held[client.id] = key
      end
    elseif action == "up" then
      pcall(rb.hid.up, key)
      if state.mouse_held[client.id] == key then
        state.mouse_held[client.id] = nil
      end
    elseif action == "click" then
      pcall(rb.hid.combo, key)
    else
      fail(client, request, "bad_mouse_action", "mouse action must be down, up, or click")
      return
    end
    reply(client, request, { ok = true })
    return
  end

  if kind == "scroll" then
    local delta = math.max(-10, math.min(10, math.floor(tonumber(request.delta) or 0)))
    if delta ~= 0 then
      pcall(rb.hid.scroll, delta)
    end
    reply(client, request, { ok = true })
    return
  end

  if kind == "media" then
    local key = media_key(request.action)
    if not key then
      fail(client, request, "bad_media", "unknown media action")
      return
    end
    pcall(rb.hid.combo, key)
    reply(client, request, { ok = true })
    return
  end

  if kind == "key" then
    local code = trim(request.code)
    if code == "" or #code > 80 then
      fail(client, request, "bad_key", "missing key code")
      return
    end
    pcall(rb.hid.combo, code)
    reply(client, request, { ok = true })
    return
  end

  if kind == "type" then
    local value = tostring(request.text or "")
    if #value > 8000 then
      fail(client, request, "too_long", "text is too long")
      return
    end
    pcall(rb.system.type_text, value)
    reply(client, request, { ok = true })
    return
  end

  if kind == "clipboard.set" then
    local value = tostring(request.text or "")
    if #value > 8000 then
      fail(client, request, "too_long", "text is too long")
      return
    end
    pcall(rb.clipboard.set, value)
    reply(client, request, { ok = true })
    return
  end

  if kind == "clipboard.get" then
    local value = ""
    local ok, text = pcall(rb.clipboard.get)
    if ok and type(text) == "string" then
      value = text
    end
    reply(client, request, { text = value })
    return
  end

  fail(client, request, "unknown_command", "unknown command '" .. kind .. "'")
end

local function kill_powershell(pid)
  pid = tonumber(pid)
  if not pid then
    return
  end
  local ok, processes = pcall(rb.process.list)
  if ok and type(processes) == "table" then
    for _, process in ipairs(processes) do
      if process.pid == pid
        and tostring(process.name or ""):lower():find("powershell", 1, true) then
        pcall(rb.process.kill, pid)
        return
      end
    end
  end
end

local function stop_http()
  if state.http_pid then
    kill_powershell(state.http_pid)
    state.http_pid = nil
  end
  pcall(rb.file.delete, PID_FILE)
end

local function start_http()
  local dir = rb.file.script_dir()
  -- Rebind reports extended-length paths on Windows; PowerShell's -File needs
  -- the ordinary drive-letter form.
  if dir:sub(1, 4) == "\\\\?\\" then
    dir = dir:sub(5)
  end
  local script_path = dir .. "/" .. SERVER_SCRIPT
  local phone_path = dir .. "/" .. PHONE_DIR
  rb.log.info("Pocket Deck page server: " .. script_path .. " root=" .. phone_path)

  if rb.file.exists(PID_FILE) then
    local old = tonumber(rb.file.read(PID_FILE) or "")
    kill_powershell(old)
    pcall(rb.file.delete, PID_FILE)
  end

  local ok, pid = pcall(rb.system.exec_detached, "powershell.exe", {
    args = {
      "-NoProfile",
      "-WindowStyle",
      "Hidden",
      "-ExecutionPolicy",
      "Bypass",
      "-File",
      script_path,
      "-Port",
      tostring(HTTP_PORT),
      "-Root",
      phone_path,
    },
  })
  if not ok then
    rb.log.warn("Pocket Deck: page server failed: " .. tostring(pid))
    notify("Could not start the phone page server.")
    return
  end
  state.http_pid = pid
  rb.log.info("Pocket Deck page server pid: " .. tostring(pid))
  pcall(rb.file.write, PID_FILE, tostring(pid))
end

local function start_ws()
  local ok, server = pcall(rb.net.ws_listen, WS_PORT, {
    on_connect = function(client)
      state.authed[client.id] = false
    end,
    on_message = function(client, payload)
      local parsed, request = pcall(rb.json.parse, payload)
      if not parsed or type(request) ~= "table" then
        return
      end
      local handled = pcall(handle, client, request)
      if not handled then
        fail(client, request, "handler_error", "command failed")
      end
    end,
    on_close = function(client)
      state.authed[client.id] = nil
      local held = state.mouse_held[client.id]
      if held then
        pcall(rb.hid.up, held)
        state.mouse_held[client.id] = nil
      end
    end,
  }, { host = "0.0.0.0" })

  if not ok then
    rb.log.warn("Pocket Deck: websocket failed: " .. tostring(server))
    notify("Could not start the phone control socket.")
    return
  end
  state.ws = server
end

function OnStart()
  if token() == "" then
    cfg.auth_token = make_token()
    rb.log.info("Pocket Deck: generated a phone access token in Options")
  end

  start_http()
  start_ws()

  if cfg.show_start_toast ~= false then
    notify("Open http://<this PC>:" .. HTTP_PORT .. " on your phone")
  end
end

function OnStop()
  for _, held in pairs(state.mouse_held) do
    pcall(rb.hid.up, held)
  end
  state.mouse_held = {}

  if state.ws then
    pcall(function()
      state.ws:stop()
    end)
    state.ws = nil
  end
  stop_http()
end

1 file without text previews