shellphone 0.1.0

Pipe CLI commands to a secure mobile web terminal
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
  <title>shellphone</title>
  <link rel="stylesheet" href="/assets/vendor/xterm.min.css">
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    html, body { height: 100%; background: #1e1e2e; overflow: hidden; }
    #terminal { height: 100%; width: 100%; }
    #status {
      position: fixed; top: 0; left: 0; right: 0;
      padding: 4px 12px;
      font-family: monospace; font-size: 12px;
      color: #cdd6f4; background: #313244;
      z-index: 10;
    }
    #status.connected { background: #1e6640; }
    #status.error { background: #8b2525; }
    #status.reconnecting { background: #7c5a00; }
  </style>
</head>
<body>
  <div id="status">Connecting...</div>
  <div id="terminal"></div>

  <script src="/assets/vendor/xterm.min.js"></script>
  <script src="/assets/vendor/addon-fit.min.js"></script>
  <script>
    const STORAGE_KEY = 'shellphone_refresh_token';
    const params = new URLSearchParams(window.location.search);
    const initialToken = params.get('token') || '';
    const statusEl = document.getElementById('status');

    const term = new window.Terminal({
      cursorBlink: true,
      fontSize: 14,
      theme: {
        background: '#1e1e2e',
        foreground: '#cdd6f4',
        cursor: '#f5e0dc',
      },
    });

    const fitAddon = new window.FitAddon.FitAddon();
    term.loadAddon(fitAddon);
    term.open(document.getElementById('terminal'));
    fitAddon.fit();

    window.addEventListener('resize', () => {
      fitAddon.fit();
      sendResize();
    });

    const resizeObserver = new ResizeObserver(() => {
      fitAddon.fit();
      sendResize();
    });
    resizeObserver.observe(document.getElementById('terminal'));

    let ws = null;
    let reconnectTimer = null;
    let sessionEnded = false;

    function sendResize() {
      if (ws && ws.readyState === WebSocket.OPEN) {
        ws.send(JSON.stringify({
          type: 'resize',
          cols: term.cols,
          rows: term.rows,
        }));
      }
    }

    function getWsUrl(useRefresh) {
      const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
      const base = `${proto}//${window.location.host}/ws`;
      if (useRefresh) {
        const refresh = localStorage.getItem(STORAGE_KEY);
        if (refresh) return `${base}?refresh=${encodeURIComponent(refresh)}`;
        return null;
      }
      return `${base}?token=${encodeURIComponent(initialToken)}`;
    }

    function connect(useRefresh) {
      const url = getWsUrl(useRefresh);
      if (!url) {
        statusEl.textContent = 'No credentials — scan QR code again';
        statusEl.className = 'error';
        return;
      }

      ws = new WebSocket(url);
      ws.binaryType = 'arraybuffer';

      ws.addEventListener('open', () => {
        statusEl.textContent = 'Connected';
        statusEl.className = 'connected';
        setTimeout(() => { statusEl.style.display = 'none'; }, 2000);
        sendResize();
      });

      ws.addEventListener('message', (event) => {
        if (event.data instanceof ArrayBuffer) {
          term.write(new Uint8Array(event.data));
        } else {
          const msg = JSON.parse(event.data);
          if (msg.type === 'refresh_token') {
            localStorage.setItem(STORAGE_KEY, msg.token);
          } else if (msg.type === 'exit') {
            sessionEnded = true;
            const code = msg.code != null ? msg.code : '?';
            term.write(`\r\n\x1b[90m[process exited with code ${code}]\x1b[0m\r\n`);
            statusEl.textContent = 'Session closed';
            statusEl.className = 'error';
            statusEl.style.display = 'block';
            localStorage.removeItem(STORAGE_KEY);
          }
        }
      });

      ws.addEventListener('close', () => {
        if (sessionEnded) return;
        statusEl.textContent = 'Reconnecting...';
        statusEl.className = 'reconnecting';
        statusEl.style.display = 'block';
        scheduleReconnect();
      });

      ws.addEventListener('error', () => {
        statusEl.textContent = 'Connection error';
        statusEl.className = 'error';
        statusEl.style.display = 'block';
      });
    }

    function scheduleReconnect() {
      if (reconnectTimer) return;
      reconnectTimer = setTimeout(() => {
        reconnectTimer = null;
        connect(true);
      }, 2000);
    }

    term.onData((data) => {
      if (ws && ws.readyState === WebSocket.OPEN) {
        ws.send(JSON.stringify({ type: 'input', data }));
      }
    });

    // Try refresh token first (returning to an existing session), fall back to initial token
    const hasRefresh = localStorage.getItem(STORAGE_KEY);
    connect(!!hasRefresh && !initialToken);
  </script>
</body>
</html>