Загрузка данных


'use strict';

/**
 * i-am-not-a-robot live relay — raw CDP over a WebSocket (NO playwright),
 * streamed to the phone over a WebSocket transport.
 *
 * Streams a real browser page to the user's phone via CDP Page.captureScreenshot
 * and forwards the human's taps back into the SAME browser session via
 * Input.dispatchMouseEvent, so a real finger solves the in-page CAPTCHA.
 *
 * Transport is a single WebSocket:
 *   frames down as binary JPEG
 *   taps up as JSON
 *
 * CAPTCHA is solved by the human. This relay does not automate CAPTCHA solving.
 *
 * Runtime deps: ws
 * Logs go to stderr; stdout is reserved for the MCP JSON-RPC channel.
 */

const http = require('http');
const os = require('os');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { spawn } = require('child_process');
const WebSocket = require('ws');
const { WebSocketServer } = WebSocket;

const TAG = '[i-am-not-a-robot:relay]';

/**
 * Structured logger -> stderr only.
 */
function makeLogger(enabled) {
  const on =
    enabled &&
    process.env.IAMNOTAROBOT_QUIET !== '1';

  return (level, msg, extra) => {
    if (!on) return;

    const line =
      `${TAG} ${level.toUpperCase()} ${msg}`;

    if (extra !== undefined) {
      console.error(line, extra);
    } else {
      console.error(line);
    }
  };
}

/**
 * All non-internal IPv4 addresses.
 *
 * RFC1918 LAN addresses are preferred.
 * Tailscale CGNAT is ranked last.
 * Docker/veth/bridge interfaces and link-local addresses are skipped.
 */
function listIpv4() {
  const out = [];
  const ifaces = os.networkInterfaces();

  for (const name of Object.keys(ifaces)) {
    if (
      /^(docker|veth|br-|virbr|cni|flannel)/i.test(name)
    ) {
      continue;
    }

    for (const net of ifaces[name] || []) {
      if (
        net.family === 'IPv4' &&
        !net.internal &&
        !/^169\.254\./.test(net.address)
      ) {
        out.push(net.address);
      }
    }
  }

  const rank = (ip) => {
    if (/^192\.168\./.test(ip)) return 0;
    if (/^10\./.test(ip)) return 0;

    if (
      /^172\.(1[6-9]|2\d|3[01])\./.test(ip)
    ) {
      return 0;
    }

    if (
      /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(ip)
    ) {
      return 2;
    }

    return 1;
  };

  out.sort((a, b) => rank(a) - rank(b));

  return out.length
    ? out
    : ['127.0.0.1'];
}

/**
 * Primary LAN IP.
 */
function detectLanIp() {
  return listIpv4()[0];
}

/**
 * Minimal raw CDP client over a page-level WebSocket.
 *
 * Features:
 * - request/response matching
 * - per-command timeout
 * - pending request cleanup
 * - event handlers
 * - safe close
 */
class CdpSession {
  constructor(wsUrl, log, options = {}) {
    this.wsUrl = wsUrl;
    this.log = log || (() => {});

    this.ws = null;
    this._id = 0;
    this._pending = new Map();
    this._handlers = new Map();

    this.closed = false;
    this.connecting = false;

    this.defaultTimeoutMs =
      Number.isFinite(options.defaultTimeoutMs)
        ? options.defaultTimeoutMs
        : 5000;
  }

  connect() {
    if (
      this.ws &&
      this.ws.readyState === WebSocket.OPEN
    ) {
      return Promise.resolve();
    }

    return new Promise((resolve, reject) => {
      this.connecting = true;
      this.closed = false;

      this.log('external', 'CDP connecting', {
        wsUrl: this.wsUrl,
      });

      const ws = new WebSocket(this.wsUrl, {
        maxPayload: 64 * 1024 * 1024,
      });

      this.ws = ws;

      let settled = false;

      const finishConnect = (err) => {
        if (settled) return;

        settled = true;
        this.connecting = false;

        if (err) {
          reject(err);
        } else {
          resolve();
        }
      };

      ws.on('open', () => {
        this.log('state', 'CDP ws open');
        finishConnect();
      });

      ws.on('message', (data) => {
        this._onMessage(data);
      });

      ws.on('error', (err) => {
        this.log(
          'error',
          'CDP ws error',
          err && err.message
        );

        finishConnect(err);
      });

      ws.on('close', () => {
        this.closed = true;
        this.connecting = false;

        this.log('state', 'CDP ws closed');

        for (const pending of this._pending.values()) {
          try {
            clearTimeout(pending.timer);
          } catch {}

          try {
            pending.reject(
              new Error('CDP socket closed')
            );
          } catch {}
        }

        this._pending.clear();
      });
    });
  }

  _onMessage(data) {
    let msg;

    try {
      msg = JSON.parse(data.toString());
    } catch {
      this.log(
        'error',
        'CDP non-JSON frame ignored'
      );
      return;
    }

    if (
      msg.id !== undefined &&
      this._pending.has(msg.id)
    ) {
      const pending =
        this._pending.get(msg.id);

      this._pending.delete(msg.id);

      try {
        clearTimeout(pending.timer);
      } catch {}

      if (msg.error) {
        pending.reject(
          new Error(
            `CDP error: ${
              msg.error.message ||
              JSON.stringify(msg.error)
            }`
          )
        );
      } else {
        pending.resolve(msg.result);
      }

      return;
    }

    if (!msg.method) return;

    const handlers =
      this._handlers.get(msg.method);

    if (!handlers) return;

    for (const handler of handlers) {
      try {
        handler(msg.params || {});
      } catch (e) {
        this.log(
          'error',
          'CDP event handler threw',
          e && e.message
        );
      }
    }
  }

  send(method, params = {}, options = {}) {
    const timeoutMs =
      Number.isFinite(options.timeoutMs)
        ? options.timeoutMs
        : this.defaultTimeoutMs;

    return new Promise((resolve, reject) => {
      if (
        !this.ws ||
        this.ws.readyState !== WebSocket.OPEN
      ) {
        reject(
          new Error('CDP socket not open')
        );
        return;
      }

      if (this.closed) {
        reject(
          new Error('CDP session is closed')
        );
        return;
      }

      const id = ++this._id;

      const timer = setTimeout(() => {
        const pending =
          this._pending.get(id);

        if (!pending) return;

        this._pending.delete(id);

        this.log(
          'warn',
          'CDP command timeout',
          {
            id,
            method,
            timeoutMs,
          }
        );

        reject(
          new Error(
            `CDP command timeout: ${method} (${timeoutMs}ms)`
          )
        );
      }, timeoutMs);

      this._pending.set(id, {
        resolve,
        reject,
        timer,
        method,
      });

      try {
        this.ws.send(
          JSON.stringify({
            id,
            method,
            params,
          }),
          (err) => {
            if (!err) return;

            const pending =
              this._pending.get(id);

            if (!pending) return;

            this._pending.delete(id);

            try {
              clearTimeout(pending.timer);
            } catch {}

            reject(err);
          }
        );
      } catch (e) {
        this._pending.delete(id);

        try {
          clearTimeout(timer);
        } catch {}

        reject(e);
      }
    });
  }

  on(method, handler) {
    if (!this._handlers.has(method)) {
      this._handlers.set(
        method,
        new Set()
      );
    }

    this._handlers
      .get(method)
      .add(handler);

    return () => {
      const handlers =
        this._handlers.get(method);

      if (!handlers) return;

      handlers.delete(handler);

      if (!handlers.size) {
        this._handlers.delete(method);
      }
    };
  }

  async close() {
    this.closed = true;

    for (const pending of this._pending.values()) {
      try {
        clearTimeout(pending.timer);
      } catch {}

      try {
        pending.reject(
          new Error('CDP session closed')
        );
      } catch {}
    }

    this._pending.clear();
    this._handlers.clear();

    const ws = this.ws;
    this.ws = null;

    if (!ws) return;

    try {
      if (
        ws.readyState === WebSocket.OPEN ||
        ws.readyState === WebSocket.CONNECTING
      ) {
        ws.close();
      }
    } catch {}
  }
}

/**
 * List inspectable targets from CDP HTTP endpoint.
 */
async function fetchTargets(cdpUrl) {
  const base =
    cdpUrl.replace(/\/+$/, '');

  const url = `${base}/json`;

  const ac = new AbortController();

  const timer = setTimeout(
    () => ac.abort(),
    8000
  );

  let res;

  try {
    res = await fetch(url, {
      redirect: 'manual',
      signal: ac.signal,
    });
  } catch (e) {
    throw new Error(
      `cannot reach CDP at ${url}: ${e.message}`
    );
  } finally {
    clearTimeout(timer);
  }

  if (!res.ok) {
    throw new Error(
      `CDP ${url} returned HTTP ${res.status}`
    );
  }

  return res.json();
}

/**
 * Strip userinfo from URL before logging.
 */
function redactUrl(u) {
  try {
    const x = new URL(u);

    if (x.username || x.password) {
      x.username = '';
      x.password = '';
      return x.toString();
    }

    return u;
  } catch {
    return u;
  }
}

/**
 * True if the debugger ws uses the same port as cdpUrl.
 *
 * We deliberately compare the port only.
 * localhost / 127.0.0.1 / tunnelled hosts can legitimately differ.
 */
function sameCdpHost(cdpUrl, wsUrl) {
  try {
    return (
      new URL(cdpUrl).port ===
      new URL(wsUrl).port
    );
  } catch {
    return false;
  }
}

/**
 * Phone page.
 *
 * Binary JPEG frames come down.
 * Normalized tap coordinates go back up.
 */
function renderPhonePage(title) {
  const esc = (s) =>
    String(s).replace(
      /[&<>"]/g,
      (c) =>
        ({
          '&': '&amp;',
          '<': '&lt;',
          '>': '&gt;',
          '"': '&quot;',
        })[c]
    );

  title = esc(title);

  return `<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta
  name="viewport"
  content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no,viewport-fit=cover"
>
<title>i-am-not-a-robot</title>
<style>
*{box-sizing:border-box}

html,body{
  margin:0;
  min-height:100%;
  background:#0b0b0f;
  color:#e8e8ea;
  font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
  -webkit-tap-highlight-color:transparent;
}

body{
  min-height:100vh;
}

header{
  position:sticky;
  top:0;
  z-index:2;
  display:flex;
  align-items:center;
  gap:9px;
  padding:11px 13px;
  background:#15151c;
  border-bottom:1px solid #26262f;
  font-size:13px;
}

.dot{
  width:9px;
  height:9px;
  border-radius:50%;
  background:#f59e0b;
  flex:0 0 auto;
  transition:background .3s;
}

.dot.live{background:#22c55e}
.dot.off{background:#ef4444}

.wrap{
  position:relative;
  width:100%;
  min-height:0;
  touch-action:manipulation;
  overflow:hidden;
}

img{
  width:100%;
  height:auto;
  min-height:1px;
  display:block;
  object-fit:contain;
  user-select:none;
  -webkit-user-select:none;
  -webkit-touch-callout:none;
}

#hint{
  padding:11px 14px;
  font-size:12px;
  color:#9aa0aa;
  text-align:center;
  line-height:1.45;
}

.ring{
  position:absolute;
  width:34px;
  height:34px;
  margin:-17px 0 0 -17px;
  border:2px solid #22c55e;
  border-radius:50%;
  pointer-events:none;
  opacity:0;
}

.ring.go{
  animation:rng .5s ease-out;
}

@keyframes rng{
  0%{
    opacity:.9;
    transform:scale(.4);
  }
  100%{
    opacity:0;
    transform:scale(1.3);
  }
}
</style>
</head>

<body>
<header>
  <span class="dot" id="dot"></span>
  <span>${title}</span>
</header>

<div class="wrap" id="wrap">
  <img id="v" alt="live page" src="">
  <div class="ring" id="ring"></div>
</div>

<div id="hint">
  Tap exactly where you would on a computer.
  When the challenge clears, you can close this page.
</div>

<script>
var img=document.getElementById('v');
var dot=document.getElementById('dot');
var ring=document.getElementById('ring');
var wrap=document.getElementById('wrap');
var hint=document.getElementById('hint');

var base=location.pathname.replace(/\\/$/,'');
var solved=false;
var curUrl=null;

var ws=new WebSocket(
  (location.protocol==='https:'?'wss://':'ws://')+
  location.host+
  base+
  '/ws'
);

ws.binaryType='blob';

ws.onmessage=function(e){
  if(typeof e.data==='string'){
    try{
      var m=JSON.parse(e.data);

      if(m.type==='solved'){
        solved=true;
        dot.className='dot live';
        img.style.opacity='0.35';

        hint.textContent=
          '\\u2713 Solved — the agent has it. You can close this page.';

        hint.style.color='#22c55e';
        hint.style.fontSize='15px';
      }
    }catch(_){}

    return;
  }

  if(solved) return;

  var url=URL.createObjectURL(e.data);
  var old=curUrl;

  curUrl=url;
  img.src=url;

  dot.className='dot live';

  if(old){
    setTimeout(function(){
      URL.revokeObjectURL(old);
    },200);
  }
};

ws.onclose=function(){
  if(!solved){
    dot.className='dot off';
  }
};

ws.onerror=function(){
  if(!solved){
    dot.className='dot off';
  }
};

function tap(cx,cy){
  var ir=img.getBoundingClientRect();

  if(!ir.width || !ir.height){
    return;
  }

  var nx=(cx-ir.left)/ir.width;
  var ny=(cy-ir.top)/ir.height;

  if(nx<0||nx>1||ny<0||ny>1){
    return;
  }

  var wr=wrap.getBoundingClientRect();

  ring.style.left=(cx-wr.left)+'px';
  ring.style.top=(cy-wr.top)+'px';

  ring.classList.remove('go');
  void ring.offsetWidth;
  ring.classList.add('go');

  if(ws.readyState===1){
    ws.send(JSON.stringify({
      nx:nx,
      ny:ny
    }));
  }
}

var dragging=false;

function pointerPos(ev){
  var p=ev.touches ? ev.touches[0] : ev;
  return {x:p.clientX,y:p.clientY};
}

function sendPointer(type,ev){
  var p=pointerPos(ev);
  var ir=img.getBoundingClientRect();

  if(!ir.width || !ir.height) return;

  var nx=(p.x-ir.left)/ir.width;
  var ny=(p.y-ir.top)/ir.height;

  if(nx<0||nx>1||ny<0||ny>1) return;

  if(ws.readyState===1){
    ws.send(JSON.stringify({
      type:type,
      nx:nx,
      ny:ny
    }));
  }
}

img.addEventListener('pointerdown',function(ev){
  ev.preventDefault();
  dragging=true;
  try{img.setPointerCapture(ev.pointerId);}catch(_){}
  sendPointer('down',ev);
});

img.addEventListener('pointermove',function(ev){
  if(!dragging) return;
  ev.preventDefault();
  sendPointer('move',ev);
});

img.addEventListener('pointerup',function(ev){
  if(!dragging) return;
  ev.preventDefault();
  dragging=false;
  sendPointer('up',ev);
});

img.addEventListener('pointercancel',function(ev){
  if(!dragging) return;
  dragging=false;
  sendPointer('up',ev);
});
</script>

</body>
</html>`;
}

/**
 * CAPTCHA clip expression.
 *
 * Finds visible reCAPTCHA/hCaptcha/Turnstile elements.
 */
const CAPTCHA_CLIP_EXPR = `(function(){
  function box(el){
    if(!el) return null;

    var r=el.getBoundingClientRect();

    if(r.width<8||r.height<8) return null;

    var st=getComputedStyle(el);

    if(
      st.visibility==='hidden' ||
      st.display==='none' ||
      parseFloat(st.opacity||'1')<0.05
    ){
      return null;
    }

    if(
      r.bottom<0 ||
      r.right<0 ||
      r.top>innerHeight ||
      r.left>innerWidth
    ){
      return null;
    }

    return {
      x:r.left,
      y:r.top,
      w:r.width,
      h:r.height
    };
  }

  var sel=[
    'iframe[src*="recaptcha/api2/anchor"]',
    'iframe[src*="recaptcha/api2/bframe"]',
    'iframe[src*="recaptcha/enterprise/anchor"]',
    'iframe[src*="recaptcha/enterprise/bframe"]',
    'iframe[title="reCAPTCHA"]',
    '.g-recaptcha',
    '[data-sitekey]',
    'iframe[src*="hcaptcha.com"]',
    'iframe[src*="newassets.hcaptcha.com"]',
    '.h-captcha',
    '.cf-turnstile',
    'iframe[src*="challenges.cloudflare.com"]'
  ];

  var rects=[];

  sel.forEach(function(s){
    document
      .querySelectorAll(s)
      .forEach(function(e){
        var b=box(e);

        if(b){
          rects.push(b);
        }

        var p=e.parentElement;

        if(p){
          var pb=box(p);

          if(
            pb &&
            pb.w<innerWidth*0.95
          ){
            rects.push(pb);
          }
        }
      });
  });

  if(!rects.length){
    return null;
  }

  var x0=Math.min.apply(
    null,
    rects.map(function(r){
      return r.x;
    })
  );

  var y0=Math.min.apply(
    null,
    rects.map(function(r){
      return r.y;
    })
  );

  var x1=Math.max.apply(
    null,
    rects.map(function(r){
      return r.x+r.w;
    })
  );

  var y1=Math.max.apply(
    null,
    rects.map(function(r){
      return r.y+r.h;
    })
  );

  var pad=10;

  x0=Math.max(0,x0-pad);
  y0=Math.max(0,y0-pad);

  x1=Math.min(
    innerWidth,
    x1+pad
  );

  y1=Math.min(
    innerHeight,
    y1+pad
  );

  return {
    x:Math.round(x0),
    y:Math.round(y0),
    width:Math.round(x1-x0),
    height:Math.round(y1-y0)
  };
})()`;

// ---------------------------------------------------------------------------
// Public tunnels
// ---------------------------------------------------------------------------

/**
 * Download a URL to a file.
 */
async function downloadFile(url, dest) {
  const res = await fetch(url, {
    redirect: 'follow',
  });

  if (!res.ok) {
    throw new Error(
      `download HTTP ${res.status}`
    );
  }

  const { Readable } =
    require('stream');

  await new Promise((resolve, reject) => {
    const out =
      fs.createWriteStream(dest);

    Readable
      .fromWeb(res.body)
      .pipe(out)
      .on('finish', resolve)
      .on('error', reject);
  });
}

/**
 * Wait until public URL becomes reachable.
 */
async function waitReachable(
  url,
  timeoutMs,
  initialDelayMs = 9000
) {
  await new Promise((resolve) =>
    setTimeout(resolve, initialDelayMs)
  );

  const deadline =
    Date.now() + timeoutMs;

  while (Date.now() < deadline) {
    try {
      const r = await fetch(url, {
        method: 'GET',
        redirect: 'manual',
      });

      if (r.status) {
        return true;
      }
    } catch {}

    await new Promise((resolve) =>
      setTimeout(resolve, 1500)
    );
  }

  return false;
}

/**
 * Ensure cloudflared exists.
 */
async function ensureCloudflared(log) {
  const plat = process.platform;
  const arch = process.arch;

  let asset;
  let binName;

  if (plat === 'linux') {
    asset =
      arch === 'arm64'
        ? 'cloudflared-linux-arm64'
        : 'cloudflared-linux-amd64';

    binName = 'cloudflared';
  } else if (plat === 'win32') {
    asset =
      'cloudflared-windows-amd64.exe';

    binName = 'cloudflared.exe';
  } else {
    return null;
  }

  const cacheDir = path.join(
    os.homedir(),
    '.cache',
    'i-am-not-a-robot'
  );

  const bin = path.join(
    cacheDir,
    binName
  );

  try {
    fs.accessSync(
      bin,
      fs.constants.X_OK
    );

    return bin;
  } catch {}

  try {
    fs.mkdirSync(cacheDir, {
      recursive: true,
    });

    const url =
      `https://github.com/cloudflare/cloudflared/releases/latest/download/${asset}`;

    log(
      'external',
      'fetching cloudflared binary (one-time)',
      { asset }
    );

    await downloadFile(url, bin);

    if (plat !== 'win32') {
      fs.chmodSync(bin, 0o755);
    }

    return bin;
  } catch (e) {
    log(
      'warn',
      'cloudflared fetch failed — will use ssh tunnel',
      e && e.message
    );

    return null;
  }
}

const TUNNEL_PROVIDERS = [
  {
    name: 'localhost.run',

    args: (port) => [
      '-o',
      'StrictHostKeyChecking=accept-new',
      '-o',
      'ServerAliveInterval=30',
      '-o',
      'ExitOnForwardFailure=yes',
      '-o',
      'ConnectTimeout=15',
      '-R',
      `80:localhost:${port}`,
      'nokey@localhost.run',
    ],

    re:
      /https:\/\/[a-z0-9-]+\.lhr\.life/i,
  },

  {
    name: 'pinggy',

    args: (port) => [
      '-o',
      'StrictHostKeyChecking=accept-new',
      '-o',
      'ServerAliveInterval=30',
      '-o',
      'ExitOnForwardFailure=yes',
      '-o',
      'ConnectTimeout=15',
      '-p',
      '443',
      '-R0',
      `localhost:${port}`,
      'a.pinggy.io',
    ],

    re:
      /https:\/\/[a-z0-9-]+\.[a-z0-9.-]*pinggy[a-z-]*\.(?:link|online)/i,
  },
];

class Tunnel {
  constructor(log) {
    this.log = log || (() => {});
    this.proc = null;
    this.url = null;
    this.provider = null;
  }

  async open(
    port,
    { timeoutMs = 20000 } = {}
  ) {
    try {
      const url =
        await this._openCloudflared(port);

      if (url) {
        this.url = url;
        this.provider =
          'cloudflared';

        this.log(
          'state',
          'tunnel up',
          {
            provider:
              'cloudflared',
            url,
          }
        );

        return url;
      }
    } catch (e) {
      this.log(
        'warn',
        'cloudflared failed — trying ssh',
        e && e.message
      );
    }

    this.close();

    for (const provider of TUNNEL_PROVIDERS) {
      this.log(
        'external',
        'opening tunnel',
        {
          provider:
            provider.name,
          port,
        }
      );

      const url =
        await this
          ._trySsh(
            provider,
            port,
            timeoutMs
          )
          .catch((e) => {
            this.log(
              'warn',
              'tunnel provider failed',
              {
                provider:
                  provider.name,
                err:
                  e &&
                  e.message,
              }
            );

            return null;
          });

      if (url) {
        this.url = url;
        this.provider =
          provider.name;

        this.log(
          'state',
          'tunnel up',
          {
            provider:
              provider.name,
            url,
          }
        );

        return url;
      }

      this.close();
    }

    this.log(
      'warn',
      'all tunnels failed — relay will be LAN-only'
    );

    return null;
  }

  async _openCloudflared(port) {
    const bin =
      await ensureCloudflared(
        this.log
      );

    if (!bin) {
      return null;
    }

    return new Promise(
      (resolve, reject) => {
        const proc = spawn(
          bin,
          [
            'tunnel',
            '--url',
            `http://localhost:${port}`,
            '--no-autoupdate',
          ]
        );

        this.proc = proc;

        let buf = '';
        let settled = false;

        const finish = (
          fn,
          arg
        ) => {
          if (settled) return;

          settled = true;
          clearTimeout(timer);
          fn(arg);
        };

        const onData = (d) => {
          buf += d.toString();

          if (buf.length > 65536) {
            buf = buf.slice(-4096);
          }

          const m =
            buf.match(
              /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i
            );

          if (!m) return;

          proc.stdout.removeListener(
            'data',
            onData
          );

          proc.stderr.removeListener(
            'data',
            onData
          );

          this.log(
            'external',
            'cloudflared url up — waiting for DNS',
            { url: m[0] }
          );

          waitReachable(
            m[0],
            20000
          ).then((ok) => {
            finish(
              ok ? resolve : reject,
              ok
                ? m[0]
                : new Error(
                    'cloudflared url not reachable'
                  )
            );
          });
        };

        proc.stdout.on(
          'data',
          onData
        );

        proc.stderr.on(
          'data',
          onData
        );

        proc.on(
          'error',
          (e) =>
            finish(reject, e)
        );

        proc.on(
          'exit',
          (code) =>
            finish(
              reject,
              new Error(
                `cloudflared exited (${code})`
              )
            )
        );

        const timer = setTimeout(() => {
          try {
            proc.kill(
              'SIGKILL'
            );
          } catch {}

          finish(
            reject,
            new Error(
              'cloudflared timeout'
            )
          );
        }, 50000);
      }
    );
  }

  _trySsh(
    provider,
    port,
    timeoutMs
  ) {
    return new Promise(
      (resolve, reject) => {
        const proc = spawn(
          'ssh',
          provider.args(port)
        );

        this.proc = proc;

        let buf = '';
        let settled = false;

        const finish = (
          fn,
          arg
        ) => {
          if (settled) return;

          settled = true;
          clearTimeout(timer);
          fn(arg);
        };

        const onData = (d) => {
          buf += d.toString();

          if (buf.length > 65536) {
            buf = buf.slice(-4096);
          }

          const m =
            buf.match(provider.re);

          if (!m) return;

          proc.stdout.removeListener(
            'data',
            onData
          );

          proc.stderr.removeListener(
            'data',
            onData
          );

          finish(
            resolve,
            m[0]
          );
        };

        proc.stdout.on(
          'data',
          onData
        );

        proc.stderr.on(
          'data',
          onData
        );

        proc.on(
          'error',
          (e) =>
            finish(reject, e)
        );

        proc.on(
          'exit',
          (code) =>
            finish(
              reject,
              new Error(
                `ssh exited (${code}) before a URL`
              )
            )
        );

        const timer = setTimeout(() => {
          try {
            proc.kill(
              'SIGKILL'
            );
          } catch {}

          finish(
            reject,
            new Error(
              'tunnel timeout'
            )
          );
        }, timeoutMs);
      }
    );
  }

  close() {
    try {
      if (this.proc) {
        this.proc.kill(
          'SIGKILL'
        );
      }
    } catch {}

    this.proc = null;
  }
}

/**
 * One live relay session.
 */
class HumanRelay {
  constructor(opts = {}) {
    this.log = makeLogger(
      opts.log !== false
    );

    this.cdp = null;

    this.server = null;
    this.wss = null;

    this.wsClients = new Set();

    this.lastFrame = null;

    this.clip = null;

    this._capTimer = null;
    this._captureBusy = false;

    this.vp = {
      w: 360,
      h: 640,
    };

    this.token = null;

    this.relayUrl = null;
    this.relayUrls = null;

    this.target = null;

    this.tunnel = null;

    this._killTimer = null;

    /*
     * JavaScript dialog state.
     */
    this._dialogWatchdog = null;
    this._dialogOpen = false;
    this._dialogInfo = null;

    /*
     * Shutdown state.
     */
    this._stopping = false;

    /*
     * Prevent overlapping solve polls.
     */
    this._solvePollBusy = false;
  }

  /**
   * Set dialog state.
   */
  _setDialogState(
    open,
    info = null
  ) {
    this._dialogOpen = !!open;
    this._dialogInfo =
      info || null;

    if (open) {
      this.log(
        'state',
        'javascript dialog is blocking page',
        info
      );
    } else {
      this.log(
        'state',
        'javascript dialog closed'
      );
    }
  }

  /**
   * Try to accept an active JavaScript dialog.
   *
   * "No dialog is showing" is intentionally treated as normal.
   */
  async _acceptJavaScriptDialog(
    source = 'unknown'
  ) {
    if (
      !this.cdp ||
      this.cdp.closed
    ) {
      return false;
    }

    try {
      await this.cdp.send(
        'Page.handleJavaScriptDialog',
        {
          accept: true,
        },
        {
          timeoutMs: 1200,
        }
      );

      this.log(
        'state',
        'javascript dialog accepted',
        { source }
      );

      this._setDialogState(false);

      return true;
    } catch (e) {
      const message =
        e && e.message
          ? e.message
          : String(e);

      if (
        /No dialog is showing/i.test(
          message
        )
      ) {
        return false;
      }

      this.log(
        'warn',
        'failed to accept javascript dialog',
        {
          source,
          error: message,
        }
      );

      return false;
    }
  }

  /**
   * Short watchdog for a dialog that may already exist
   * or appear immediately around CDP attachment.
   */
  _startDialogWatchdog() {
    this._stopDialogWatchdog();

    let attempts = 0;

    this._dialogWatchdog =
      setInterval(
        async () => {
          if (
            !this.cdp ||
            this.cdp.closed
          ) {
            return;
          }

          if (!this._dialogOpen) {
            return;
          }

          attempts += 1;

          const accepted =
            await this._acceptJavaScriptDialog(
              'watchdog'
            );

          if (
            accepted ||
            attempts >= 20
          ) {
            this._stopDialogWatchdog();
          }
        },
        250
      );

    if (
      this._dialogWatchdog.unref
    ) {
      this._dialogWatchdog.unref();
    }
  }

  _stopDialogWatchdog() {
    if (!this._dialogWatchdog) {
      return;
    }

    clearInterval(
      this._dialogWatchdog
    );

    this._dialogWatchdog = null;
  }

  /**
   * Connect via CDP, start screencast/capture and serve phone relay.
   */
  async start({
    cdpUrl,
    targetUrl,
    host,
    port,
    tunnel,
    maxLifetimeMs,
  } = {}) {
    this._stopping = false;

    this.log(
      'entry',
      'start_human_relay',
      {
        cdpUrl:
          redactUrl(cdpUrl),
        targetUrl,
      }
    );

    if (!cdpUrl) {
      throw new Error(
        'cdpUrl is required, e.g. http://localhost:9222'
      );
    }

    if (
      /^wss?:\/\//i.test(cdpUrl)
    ) {
      throw new Error(
        'pass the CDP HTTP endpoint (http://host:port), not a ws:// url'
      );
    }

    const targets =
      await fetchTargets(cdpUrl);

    const pages =
      targets.filter(
        (t) =>
          t.type === 'page' &&
          t.webSocketDebuggerUrl
      );

    this.log(
      'state',
      'targets fetched',
      {
        total:
          targets.length,
        pages:
          pages.length,
      }
    );

    let target =
      targetUrl
        ? pages.find((p) =>
            (p.url || '')
              .includes(targetUrl)
          )
        : null;

    if (!target) {
      target =
        pages.find(
          (p) =>
            p.url &&
            !/^(about:|chrome:|devtools:)/.test(
              p.url
            )
        ) ||
        pages[0];
    }

    if (!target) {
      throw new Error(
        'no inspectable page target found at cdpUrl'
      );
    }

    if (
      !sameCdpHost(
        cdpUrl,
        target.webSocketDebuggerUrl
      )
    ) {
      throw new Error(
        'CDP debugger ws host does not match cdpUrl host (refusing to dial elsewhere)'
      );
    }

    this.target = target;

    this.log(
      'state',
      'target picked',
      {
        targetId:
          target.id,
        type:
          target.type,
        title:
          target.title,
        url:
          redactUrl(target.url),
      }
    );

    this.cdp =
      new CdpSession(
        target.webSocketDebuggerUrl,
        this.log,
        {
          defaultTimeoutMs: 5000,
        }
      );

    await this.cdp.connect();

    /*
     * Register Page event handlers BEFORE Page.enable.
     *
     * This minimizes the race window around javascript dialogs.
     */
    this.cdp.on(
      'Page.javascriptDialogOpening',
      async (params = {}) => {
        const info = {
          type:
            params.type || null,

          message:
            params.message || '',

          url:
            redactUrl(
              params.url || ''
            ),

          hasBrowserHandler:
            params.hasBrowserHandler !== false,
        };

        this._setDialogState(
          true,
          info
        );

        this.log(
          'state',
          'javascript dialog opening',
          info
        );

        /*
         * Accept immediately.
         */
        await this._acceptJavaScriptDialog(
          'javascriptDialogOpening'
        );

        /*
         * Keep a short watchdog in case
         * the native dialog appears just after
         * the event.
         */
        this._startDialogWatchdog();
      }
    );

    this.cdp.on(
      'Page.javascriptDialogClosed',
      (params = {}) => {
        this._setDialogState(
          false,
          {
            accepted:
              params.result === true,

            hasBrowserHandler:
              params.hasBrowserHandler !== false,
          }
        );

        this._stopDialogWatchdog();
      }
    );

    await this.cdp.send(
      'Page.enable'
    );

    /*
     * A dialog may already exist before CDP attached.
     *
     * Try several short times.
     * "No dialog is showing" is normal.
     */
    for (let i = 0; i < 5; i += 1) {
      const accepted =
        await this._acceptJavaScriptDialog(
          `initial-${i + 1}`
        );

      if (accepted) {
        break;
      }

      await new Promise(
        (resolve) =>
          setTimeout(
            resolve,
            150
          )
      );
    }

    await this.cdp.send(
      'Runtime.enable'
    );

    /*
     * Background tabs may not composite.
     */
    try {
      await this.cdp.send(
        'Page.bringToFront',
        {},
        {
          timeoutMs: 3000,
        }
      );
    } catch {}

    /*
     * Detect viewport.
     */
    try {
      const r =
        await this.cdp.send(
          'Runtime.evaluate',
          {
            expression:
              '({w:window.innerWidth,h:window.innerHeight})',
            returnByValue:
              true,
          },
          {
            timeoutMs: 3000,
          }
        );

      if (
        r &&
        r.result &&
        r.result.value
      ) {
        this.vp =
          r.result.value;
      }
    } catch (e) {
      this.log(
        'warn',
        'viewport eval failed, using default',
        e && e.message
      );
    }

    this.log(
      'state',
      'viewport',
      this.vp
    );

    /*
     * Start CAPTCHA capture.
     */
    this._startCapture();

    this.log(
      'state',
      'captcha capture started'
    );

    this.token =
      crypto.randomBytes(12)
        .toString('hex');

    await this._startHttp({
      host,
      port,
    });

    /*
     * Public tunnel.
     */
    const tunnelMode =
      tunnel !== undefined
        ? tunnel
        : (
            process.env
              .IAMNOTAROBOT_TUNNEL ===
            'off'
              ? 'off'
              : 'auto'
          );

    if (
      tunnelMode !== 'off' &&
      !host
    ) {
      this.tunnel =
        new Tunnel(
          this.log
        );

      const localPort =
        this.server
          .address()
          .port;

      const pub =
        await this.tunnel
          .open(localPort)
          .catch((e) => {
            this.log(
              'warn',
              'tunnel open failed',
              e && e.message
            );

            return null;
          });

      if (pub) {
        const publicUrl =
          `${pub.replace(/\/+$/, '')}/r/${this.token}/`;

        this.relayUrls = [
          publicUrl,
          ...(this.relayUrls || []),
        ];

        this.relayUrl =
          publicUrl;
      } else {
        this.tunnel = null;
      }
    }

    const maxMs =
      Number.isFinite(
        maxLifetimeMs
      )
        ? maxLifetimeMs
        : 15 * 60 * 1000;

    this._killTimer =
      setTimeout(
        () => {
          this.log(
            'warn',
            'relay hit max lifetime — auto-stopping'
          );

          this.stop();
        },
        maxMs
      );

    if (
      this._killTimer.unref
    ) {
      this._killTimer.unref();
    }

    this.log(
      'exit',
      'relay live',
      {
        relayUrl:
          this.relayUrl,

        tunnel:
          this.tunnel
            ? this.tunnel.provider
            : 'none',
      }
    );

    return {
      relayUrl:
        this.relayUrl,

      relayUrls:
        this.relayUrls,

      pageUrl:
        target.url,

      viewport:
        this.vp,
    };
  }

  _startHttp({
    host,
    port,
  } = {}) {
    return new Promise(
      (resolve, reject) => {
        const prefix =
          `/r/${this.token}`;

        const tokenBuf =
          Buffer.from(
            this.token
          );

        const checkToken =
          (reqPath) => {
            const parts =
              reqPath.split('/');

            const given =
              parts[2] || '';

            const ok =
              parts[1] === 'r' &&
              given.length ===
                this.token.length &&
              crypto.timingSafeEqual(
                Buffer.from(given),
                tokenBuf
              );

            return ok
              ? parts
              : null;
          };

        const server =
          http.createServer(
            (req, res) => {
              const reqPath =
                (req.url || '')
                  .split('?')[0];

              const parts =
                checkToken(reqPath);

              if (!parts) {
                this.log(
                  'warn',
                  'rejected request with bad/expired token'
                );

                res
                  .writeHead(404)
                  .end('not found');

                return;
              }

              const sub =
                '/' +
                parts
                  .slice(3)
                  .join('/');

              if (
                sub === '/' &&
                (req.method || 'GET') ===
                  'GET'
              ) {
                this.log(
                  'debug',
                  'phone opened relay page'
                );

                res.writeHead(
                  200,
                  {
                    'Content-Type':
                      'text/html; charset=utf-8',

                    'Content-Security-Policy':
                      "default-src 'none'; img-src blob: data:; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self' ws: wss:",

                    'X-Content-Type-Options':
                      'nosniff',

                    'Referrer-Policy':
                      'no-referrer',
                  }
                );

                res.end(
                  renderPhonePage(
                    'i-am-not-a-robot · solve the CAPTCHA'
                  )
                );

                return;
              }

              res
                .writeHead(404)
                .end('not found');
            }
          );

        /*
         * WebSocket transport:
         * JPEG frames down,
         * normalized taps up.
         */
        this.wss =
          new WebSocketServer({
            noServer: true,
          });

        server.on(
          'upgrade',
          (
            req,
            socket,
            head
          ) => {
            const reqPath =
              (req.url || '')
                .split('?')[0];

            const parts =
              checkToken(reqPath);

            if (
              !parts ||
              parts[3] !== 'ws'
            ) {
              socket.destroy();
              return;
            }

            this.wss.handleUpgrade(
              req,
              socket,
              head,
              (ws) => {
                this.wsClients.add(
                  ws
                );

                this.log(
                  'state',
                  'phone WS connected',
                  {
                    clients:
                      this.wsClients.size,
                  }
                );

                if (this.lastFrame) {
                  try {
                    ws.send(
                      Buffer.from(
                        this.lastFrame,
                        'base64'
                      )
                    );
                  } catch {}
                }

                ws.on(
                  'message',
                  (data) => {
                    try {
                      const {
                        nx,
                        ny,
                      } =
                        JSON.parse(
                          data.toString()
                        );

                      this._tap(
                        nx,
                        ny
                      ).catch((e) => {
                        this.log(
                          'warn',
                          'tap relay failed',
                          e && e.message
                        );
                      });
                    } catch (e) {
                      this.log(
                        'error',
                        'bad WS tap message',
                        e && e.message
                      );
                    }
                  }
                );

                ws.on(
                  'close',
                  () => {
                    this.wsClients.delete(
                      ws
                    );

                    this.log(
                      'state',
                      'phone WS left',
                      {
                        clients:
                          this.wsClients.size,
                      }
                    );
                  }
                );

                ws.on(
                  'error',
                  () => {
                    this.wsClients.delete(
                      ws
                    );
                  }
                );
              }
            );
          }
        );

        server.on(
          'error',
          reject
        );

        server.listen(
          port || 0,
          () => {
            const p =
              server
                .address()
                .port;

            this.server =
              server;

            const ips =
              listIpv4();

            this.relayUrls =
              ips.map(
                (ip) =>
                  `http://${ip}:${p}${prefix}/`
              );

            this.relayUrl =
              host
                ? `http://${host}:${p}${prefix}/`
                : this.relayUrls[0];

            this.log(
              'state',
              'relay http+ws listening',
              {
                primary:
                  this.relayUrl,

                candidates:
                  this.relayUrls,
              }
            );

            resolve();
          }
        );
      }
    );
  }

  /**
   * Capture loop.
   *
   * Important:
   * there is no overlapping async capture.
   */
  _startCapture() {
    this._lastClip = null;
    this._missCount = 0;
    this._captureBusy = false;

    const tick = async () => {
      if (this._stopping) return;

      if (
        !this.cdp ||
        this.cdp.closed
      ) {
        return;
      }

      /*
       * A JS dialog blocks the page.
       * Don't stack Runtime.evaluate calls behind it.
       */
      if (this._dialogOpen) {
        return;
      }

      if (this._captureBusy) {
        return;
      }

      if (
        this.wsClients.size === 0 &&
        this.lastFrame
      ) {
        return;
      }

      this._captureBusy = true;

      try {
        let clip =
          await this._captchaClip();

        /*
         * Hold the last valid CAPTCHA box through
         * short detection gaps.
         */
        if (clip) {
          this._lastClip = clip;
          this._missCount = 0;
        } else if (
          this._lastClip &&
          this._missCount < 6
        ) {
          this._missCount++;
          clip = this._lastClip;
        } else {
          this._missCount++;
        }

        this.clip = clip;

        const params = {
          format: 'jpeg',
          quality: 80,
        };

        if (clip) {
          params.clip = {
            x: clip.x,
            y: clip.y,
            width: clip.width,
            height: clip.height,
            scale: 1,
          };
        }

        const shot =
          await this.cdp.send(
            'Page.captureScreenshot',
            params,
            {
              timeoutMs: 4000,
            }
          );

        if (
          !shot ||
          !shot.data
        ) {
          return;
        }

        this.lastFrame =
          shot.data;

        const bin =
          Buffer.from(
            shot.data,
            'base64'
          );

        for (
          const ws of this.wsClients
        ) {
          if (
            ws.readyState !==
            WebSocket.OPEN
          ) {
            continue;
          }

          try {
            ws.send(bin);
          } catch {}
        }
      } catch (e) {
        const message =
          e && e.message
            ? e.message
            : String(e);

        if (
          !/No dialog is showing/i.test(
            message
          )
        ) {
          this.log(
            'debug',
            'capture tick failed',
            message
          );
        }
      } finally {
        this._captureBusy = false;
      }
    };

    this._capTimer =
      setInterval(
        tick,
        90
      );

    if (
      this._capTimer.unref
    ) {
      this._capTimer.unref();
    }

    /*
     * First frame immediately.
     */
    tick();
  }

  /**
   * Visible CAPTCHA bounding box.
   */
  async _captchaClip() {
    if (
      !this.cdp ||
      this.cdp.closed
    ) {
      return null;
    }

    if (this._dialogOpen) {
      return null;
    }

    try {
      const r =
        await this.cdp.send(
          'Runtime.evaluate',
          {
            expression:
              CAPTCHA_CLIP_EXPR,

            returnByValue:
              true,
          },
          {
            timeoutMs: 3000,
          }
        );

      return (
        r &&
        r.result &&
        r.result.value
          ? r.result.value
          : null
      );
    } catch {
      return null;
    }
  }

  /**
   * Forward human tap from normalized phone coordinates
   * into the real Chrome page.
   */
  async _mouseDown(nx, ny) {
    nx = Number(nx);
    ny = Number(ny);

    if(!Number.isFinite(nx) || !Number.isFinite(ny) ||
       nx < 0 || nx > 1 || ny < 0 || ny > 1) return;

    if(!this.cdp || this.cdp.closed) return;

    const clip = this.clip || {
      x:0,
      y:0,
      width:this.vp.w,
      height:this.vp.h,
    };

    const x = Math.round(clip.x + nx * clip.width);
    const y = Math.round(clip.y + ny * clip.height);

    this._dragging = true;

    this.log('state','drag start -> page',{x,y});

    await this.cdp.send(
      'Input.dispatchMouseEvent',
      {
        type:'mouseMoved',
        x,
        y,
        buttons:0,
      },
      {timeoutMs:3000}
    );

    await this.cdp.send(
      'Input.dispatchMouseEvent',
      {
        type:'mousePressed',
        x,
        y,
        button:'left',
        buttons:1,
        clickCount:1,
      },
      {timeoutMs:3000}
    );
  }

  async _mouseMove(nx, ny) {
    nx = Number(nx);
    ny = Number(ny);

    if(!this._dragging) return;

    if(!Number.isFinite(nx) || !Number.isFinite(ny) ||
       nx < 0 || nx > 1 || ny < 0 || ny > 1) return;

    if(!this.cdp || this.cdp.closed) return;

    const clip = this.clip || {
      x:0,
      y:0,
      width:this.vp.w,
      height:this.vp.h,
    };

    const x = Math.round(clip.x + nx * clip.width);
    const y = Math.round(clip.y + ny * clip.height);

    await this.cdp.send(
      'Input.dispatchMouseEvent',
      {
        type:'mouseMoved',
        x,
        y,
        buttons:1,
      },
      {timeoutMs:3000}
    );
  }

  async _mouseUp(nx, ny) {
    nx = Number(nx);
    ny = Number(ny);

    if(!this._dragging) return;

    this._dragging = false;

    if(!Number.isFinite(nx) || !Number.isFinite(ny) ||
       nx < 0 || nx > 1 || ny < 0 || ny > 1) return;

    if(!this.cdp || this.cdp.closed) return;

    const clip = this.clip || {
      x:0,
      y:0,
      width:this.vp.w,
      height:this.vp.h,
    };

    const x = Math.round(clip.x + nx * clip.width);
    const y = Math.round(clip.y + ny * clip.height);

    this.log('state','drag end -> page',{x,y});

    await this.cdp.send(
      'Input.dispatchMouseEvent',
      {
        type:'mouseReleased',
        x,
        y,
        button:'left',
        buttons:0,
        clickCount:1,
      },
      {timeoutMs:3000}
    );
  }
  async _tap(nx, ny) {
    nx = Number(nx);
    ny = Number(ny);

    if (
      !Number.isFinite(nx) ||
      !Number.isFinite(ny) ||
      nx < 0 ||
      nx > 1 ||
      ny < 0 ||
      ny > 1
    ) {
      this.log(
        'warn',
        'tap ignored: coords out of [0,1]',
        {
          nx,
          ny,
        }
      );

      return;
    }

    if (
      !this.cdp ||
      this.cdp.closed
    ) {
      this.log(
        'warn',
        'tap ignored: CDP unavailable'
      );

      return;
    }

    /*
     * If the browser is blocked by a JavaScript dialog,
     * accept it first and don't send the same tap
     * through to the page.
     */
    if (this._dialogOpen) {
      this.log(
        'state',
        'tap received while javascript dialog is open — accepting dialog first'
      );

      await this._acceptJavaScriptDialog(
        'tap'
      );

      return;
    }

    /*
     * Map normalized coordinates from the cropped image
     * back into the full browser viewport.
     */
    const clip =
      this.clip || {
        x: 0,
        y: 0,
        width:
          this.vp.w,
        height:
          this.vp.h,
      };

    const x =
      Math.round(
        clip.x +
          nx * clip.width
      );

    const y =
      Math.round(
        clip.y +
          ny * clip.height
      );

    this.log(
      'state',
      'tap relayed -> page',
      {
        x,
        y,
      }
    );

    await this.cdp.send(
      'Input.dispatchMouseEvent',
      {
        type:
          'mouseMoved',
        x,
        y,
      },
      {
        timeoutMs: 3000,
      }
    );

    await this.cdp.send(
      'Input.dispatchMouseEvent',
      {
        type:
          'mousePressed',
        x,
        y,
        button:
          'left',
        clickCount:
          1,
      },
      {
        timeoutMs: 3000,
      }
    );

    await this.cdp.send(
      'Input.dispatchMouseEvent',
      {
        type:
          'mouseReleased',
        x,
        y,
        button:
          'left',
        clickCount:
          1,
      },
      {
        timeoutMs: 3000,
      }
    );
  }

  /**
   * Poll until CAPTCHA token appears.
   *
   * CAPTCHA is solved by the human.
   */
  async awaitSolve({
    timeoutMs = 300000,
    pollMs = 1500,
  } = {}) {
    this.log(
      'entry',
      'await_human_solve',
      {
        timeoutMs,
        pollMs,
      }
    );

    if (!this.cdp) {
      throw new Error(
        'no active relay; call start_human_relay first'
      );
    }

    const expr =
      '(function(){try{' +
      'function v(s){var e=document.querySelector(s);return e&&e.value?e.value.length:0;}' +
      'var n=0;' +
      "n+=v('textarea[id^=\"g-recaptcha-response\"]');" +
      "n+=v('textarea[name=\"h-captcha-response\"]');" +
      "n+=v('input[name=\"cf-turnstile-response\"]');" +
      "if(!n&&typeof grecaptcha!=='undefined'&&grecaptcha.getResponse){try{n+=(grecaptcha.getResponse()||'').length;}catch(e){}}" +
      "if(!n&&typeof hcaptcha!=='undefined'&&hcaptcha.getResponse){try{n+=(hcaptcha.getResponse()||'').length;}catch(e){}}" +
      'return n>0;}catch(e){return false;}})()';

    const deadline =
      Date.now() + timeoutMs;

    let polls = 0;

    while (
      Date.now() < deadline
    ) {
      if (
        this.cdp.closed
      ) {
        this.log(
          'error',
          'CDP connection dropped — stopping poll'
        );

        return {
          passed:
            false,
          reason:
            'cdp_closed',
        };
      }

      /*
       * Do not send Runtime.evaluate while a
       * native JS dialog blocks the page.
       */
      if (this._dialogOpen) {
        await this._acceptJavaScriptDialog(
          'awaitSolve'
        );

        await new Promise(
          (resolve) =>
            setTimeout(
              resolve,
              250
            )
        );

        continue;
      }

      if (this._solvePollBusy) {
        await new Promise(
          (resolve) =>
            setTimeout(
              resolve,
              pollMs
            )
        );

        continue;
      }

      this._solvePollBusy = true;

      try {
        const r =
          await this.cdp.send(
            'Runtime.evaluate',
            {
              expression:
                expr,

              returnByValue:
                true,
            },
            {
              timeoutMs: 4000,
            }
          );

        polls++;

        if (
          r &&
          r.result &&
          r.result.value === true
        ) {
          this.log(
            'exit',
            'solve detected — passed',
            {
              polls,
            }
          );

          /*
           * Tell phone before relay teardown.
           */
          for (
            const ws of this.wsClients
          ) {
            if (
              ws.readyState !==
              WebSocket.OPEN
            ) {
              continue;
            }

            try {
              ws.send(
                JSON.stringify({
                  type:
                    'solved',
                })
              );
            } catch {}
          }

          await new Promise(
            (resolve) =>
              setTimeout(
                resolve,
                250
              )
          );

          return {
            passed:
              true,
          };
        }
      } catch (e) {
        this.log(
          'warn',
          'poll eval failed',
          e && e.message
        );
      } finally {
        this._solvePollBusy = false;
      }

      await new Promise(
        (resolve) =>
          setTimeout(
            resolve,
            pollMs
          )
      );
    }

    this.log(
      'exit',
      'timed out waiting for human — not passed',
      {
        polls,
      }
    );

    return {
      passed:
        false,
      reason:
        'timeout',
    };
  }

  /**
   * Full shutdown.
   */
  async stop() {
    if (this._stopping) {
      return;
    }

    this._stopping = true;

    this.log(
      'entry',
      'stop relay'
    );

    if (this._killTimer) {
      clearTimeout(
        this._killTimer
      );

      this._killTimer = null;
    }

    if (this._capTimer) {
      clearInterval(
        this._capTimer
      );

      this._capTimer = null;
    }

    this._stopDialogWatchdog();

    this._captureBusy = false;
    this._solvePollBusy = false;

    try {
      if (this.tunnel) {
        this.tunnel.close();
      }
    } catch {}

    this.tunnel = null;

    for (
      const ws of this.wsClients
    ) {
      try {
        ws.close();
      } catch {}
    }

    this.wsClients.clear();

    try {
      if (this.wss) {
        this.wss.close();
      }
    } catch {}

    try {
      if (this.server) {
        await new Promise(
          (resolve) => {
            try {
              this.server.close(
                () => resolve()
              );
            } catch {
              resolve();
            }
          }
        );
      }
    } catch {}

    try {
      if (this.cdp) {
        await this.cdp.close();
      }
    } catch {}

    this.cdp = null;
    this.server = null;
    this.wss = null;

    this._dialogOpen = false;
    this._dialogInfo = null;

    this.lastFrame = null;
    this.clip = null;
    this._lastClip = null;

    this.log(
      'state',
      'relay stopped'
    );

    this._stopping = false;
  }
}

module.exports = {
  HumanRelay,
  CdpSession,
  Tunnel,
  detectLanIp,
  listIpv4,
  fetchTargets,
  ensureCloudflared,
};