shellphone 0.2.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 { width: 100%; position: absolute; top: 0; bottom: 0; }
    #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; }
    #toolbar {
      display: none;
      position: fixed; bottom: 0; left: 0; right: 0;
      background: #313244; border-top: 1px solid #45475a;
      padding: 6px 4px; z-index: 10;
      gap: 3px;
      justify-content: center;
    }
    #toolbar button {
      background: #45475a; color: #cdd6f4; border: none; border-radius: 4px;
      font-family: monospace; font-size: 14px;
      height: 40px;
      padding: 0;
      flex: 1 1 0;
      -webkit-tap-highlight-color: transparent;
      touch-action: manipulation;
    }
    #toolbar button:active { background: #585b70; }
    #toolbar .spacer { width: 6px; flex-shrink: 0; }
  </style>
</head>
<body>
  <div id="status">Connecting...</div>
  <div id="terminal"></div>
  <div id="toolbar">
    <button data-key="Escape">Esc</button>
    <button data-key="Tab">Tab</button>
    <div class="spacer"></div>
    <button data-key="ArrowUp">&uarr;</button>
    <button data-key="ArrowDown">&darr;</button>
    <button data-key="ArrowLeft">&larr;</button>
    <button data-key="ArrowRight">&rarr;</button>
    <button data-key="Enter">Enter</button>
  </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 isTouchDevice = ('ontouchstart' in window) || navigator.maxTouchPoints > 0;

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

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

    // Override xterm's touch scroll with a 3x multiplier for smoother mobile scrolling
    if (isTouchDevice) {
      const vp = term.element.querySelector('.xterm-viewport');
      let lastY = 0;
      vp.addEventListener('touchstart', (e) => { lastY = e.touches[0].pageY; }, { passive: true });
      vp.addEventListener('touchmove', (e) => {
        const dy = lastY - e.touches[0].pageY;
        lastY = e.touches[0].pageY;
        vp.scrollTop += dy * 2;
      }, { passive: true });
    }

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

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

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

    // Mobile: resize terminal when virtual keyboard appears/disappears
    const terminalEl = document.getElementById('terminal');
    const toolbarEl = document.getElementById('toolbar');

    if (isTouchDevice) {
      toolbarEl.style.display = 'flex';
    }

    function updateLayout() {
      const vv = window.visualViewport;
      const keyboardOffset = vv ? window.innerHeight - vv.height : 0;
      const viewportHeight = vv ? vv.height : window.innerHeight;
      const toolbarHeight = isTouchDevice ? toolbarEl.offsetHeight : 0;
      if (isTouchDevice) {
        toolbarEl.style.bottom = keyboardOffset + 'px';
      }
      terminalEl.style.height = (viewportHeight - toolbarHeight) + 'px';
      fitAddon.fit();
      sendResize();
    }

    if (window.visualViewport) {
      window.visualViewport.addEventListener('resize', updateLayout);
      window.visualViewport.addEventListener('scroll', updateLayout);
    }
    updateLayout();

    // Mobile toolbar
    const KEY_MAP = {
      Escape: '\x1b',
      Tab: '\t',
      ArrowUp: '\x1b[A',
      ArrowDown: '\x1b[B',
      ArrowLeft: '\x1b[D',
      ArrowRight: '\x1b[C',
      Enter: '\r',
    };

    toolbarEl.addEventListener('pointerdown', (e) => {
      const btn = e.target.closest('button');
      if (!btn || !btn.dataset.key) return;
      e.preventDefault();
      const key = KEY_MAP[btn.dataset.key] || btn.dataset.key;
      if (ws && ws.readyState === WebSocket.OPEN) {
        ws.send(JSON.stringify({ type: 'input', data: key }));
      }
    });

    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>