<!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 }));
}
});
const hasRefresh = localStorage.getItem(STORAGE_KEY);
connect(!!hasRefresh && !initialToken);
</script>
</body>
</html>