<!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">↑</button>
<button data-key="ArrowDown">↓</button>
<button data-key="ArrowLeft">←</button>
<button data-key="ArrowRight">→</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();
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'));
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();
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 }));
}
});
const hasRefresh = localStorage.getItem(STORAGE_KEY);
connect(!!hasRefresh && !initialToken);
</script>
</body>
</html>