Files
homeclaw/web-ui/html/android-emulator.html
T
kimandClaude Sonnet 5 3ae9b986e9 feat: 안드로이드 에뮬레이터 관리콘솔/ADB 쉘 + 컨테이너 정리
세션 영속화, 관리콘솔 WS 프록시(attachAndroidConsoleWsProxy), ADB 쉘
엔드포인트, 고아 컨테이너 정리 로직 추가. 2026-07-31 실사용 검증 완료된
작업을 커밋만 안 하고 남겨뒀던 것.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 13:13:15 +09:00

293 lines
14 KiB
HTML

<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<title>Android 에뮬레이터</title>
<link rel="stylesheet" href="/vendor/xterm.css">
<script src="/vendor/xterm.js"></script>
<style>
html,body{margin:0;padding:0;height:100%;background:#1a1a1a;color:#eee;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;overflow:hidden}
#wrap{display:flex;flex-direction:column;height:100%}
#bar{flex:0 0 auto;display:flex;align-items:center;gap:10px;padding:6px 10px;background:#222;border-bottom:1px solid #333;font-size:12px}
#bar code{background:#000;color:#8f8;padding:2px 6px;border-radius:4px;cursor:pointer;user-select:all}
#status{color:#aaa}
#consoleBtn{background:#333;color:#ccc;border:1px solid #444;border-radius:4px;padding:3px 8px;cursor:pointer;font-size:12px}
#consoleBtn.active{background:#2d5a3d;color:#cfc;border-color:#3a7a4d}
#body{flex:1;min-height:0;display:flex}
#stage{flex:1;min-height:0;position:relative;overflow:auto}
#vnc-frame{position:absolute;top:0;left:0;width:100%;height:100%;border:none;background:#000}
#zoomCtl{display:flex;align-items:center;gap:6px}
#zoomCtl input[type=range]{width:110px}
#zoomVal{color:#aaa;min-width:38px;text-align:right}
#loading{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;color:#ccc}
.spinner{width:36px;height:36px;border:3px solid #444;border-top-color:#7c5c2e;border-radius:50%;animation:spin 0.9s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
#err{position:absolute;inset:0;display:none;flex-direction:column;align-items:center;justify-content:center;gap:8px;color:#f88;padding:20px;text-align:center}
#console-panel{display:none;flex-direction:column;width:480px;flex-shrink:0;border-left:1px solid #333;background:#000}
#console-drag{flex:0 0 auto;height:5px;cursor:ew-resize;background:transparent}
#console-drag:hover{background:rgba(124,92,46,.4)}
#console-tabs{flex:0 0 auto;display:flex;background:#222;border-bottom:1px solid #333;font-size:12px}
.console-tab{padding:6px 12px;cursor:pointer;color:#888;border-bottom:2px solid transparent}
.console-tab.active{color:#eee;border-bottom-color:#7c5c2e}
.console-pane{flex:1;min-height:0;display:none;flex-direction:column}
.console-pane.active{display:flex}
.console-term{flex:1;min-height:0;padding:4px}
.console-input-row{flex:0 0 auto;padding:6px 8px;background:#161616;border-top:1px solid #333}
.console-input{width:100%;box-sizing:border-box;background:#000;color:#eee;border:1px solid #444;border-radius:4px;padding:6px 8px;font-family:'IBM Plex Mono',monospace;font-size:13px}
.console-input:focus{outline:none;border-color:#7c5c2e}
</style>
</head>
<body>
<div id="wrap">
<div id="bar">
<span id="status">준비 중…</span>
<span style="flex:1"></span>
<div id="zoomCtl" style="display:none">
<span>🔍</span>
<input type="range" id="zoomSlider" min="100" max="400" step="10" value="100">
<span id="zoomVal">100%</span>
</div>
<button id="consoleBtn" style="display:none">🖥️ 콘솔</button>
<span id="adbHint" style="display:none">adb: <code id="adbCmd" title="클릭해서 복사"></code></span>
</div>
<div id="body">
<div id="stage">
<div id="loading"><div class="spinner"></div><div id="loadingText">Android 에뮬레이터 부팅 중… (최대 90초)</div></div>
<div id="err"><div id="errText"></div></div>
<iframe id="vnc-frame" style="display:none"></iframe>
</div>
<div id="console-panel">
<div id="console-drag"></div>
<div id="console-tabs">
<div class="console-tab active" data-tab="mgmt">관리 콘솔</div>
<div class="console-tab" data-tab="shell">ADB 쉘</div>
</div>
<div class="console-pane active" id="pane-mgmt">
<div class="console-term" id="console-term"></div>
<div class="console-input-row">
<input type="text" class="console-input" id="console-input" placeholder="명령 입력 후 Enter (예: avd status)" autocomplete="off">
</div>
</div>
<div class="console-pane" id="pane-shell">
<div class="console-term" id="shell-term"></div>
<div class="console-input-row">
<input type="text" class="console-input" id="shell-input" placeholder="adb shell 명령 입력 후 Enter (예: pm list packages)" autocomplete="off">
</div>
</div>
</div>
</div>
</div>
<script>
(function(){
const params = new URLSearchParams(location.search);
const sessionId = params.get('session');
const isLoading = params.get('loading') === '1';
const statusEl = document.getElementById('status');
const loadingEl = document.getElementById('loading');
const errEl = document.getElementById('err');
const errTextEl = document.getElementById('errText');
const frameEl = document.getElementById('vnc-frame');
const adbHintEl = document.getElementById('adbHint');
const adbCmdEl = document.getElementById('adbCmd');
const zoomCtlEl = document.getElementById('zoomCtl');
const zoomSliderEl = document.getElementById('zoomSlider');
const zoomValEl = document.getElementById('zoomVal');
const consoleBtnEl = document.getElementById('consoleBtn');
const consolePanelEl = document.getElementById('console-panel');
const consoleTermEl = document.getElementById('console-term');
const consoleDragEl = document.getElementById('console-drag');
const consoleInputEl = document.getElementById('console-input');
const shellTermEl = document.getElementById('shell-term');
const shellInputEl = document.getElementById('shell-input');
// The emulator's own skinned window only fills a fraction of its 1600x900
// virtual desktop (a limitation of this docker image we can't fix from our
// side — tried EMULATOR_NO_SKIN, the emulator's autoconfig re-applies a
// skin regardless). noVNC's scale=true already stretches that whole
// desktop to fill #vnc-frame, so the only way to make the phone itself
// bigger is to make #vnc-frame bigger than its visible box and let #stage
// (overflow:auto) scroll/pan across it — genuine iframe resize rather than
// a CSS transform, so noVNC recomputes its own fit-to-frame scale and
// pointer/click coordinates stay correctly mapped.
zoomSliderEl.addEventListener('input', () => {
const pct = zoomSliderEl.value;
zoomValEl.textContent = pct + '%';
frameEl.style.width = pct + '%';
frameEl.style.height = pct + '%';
});
if (!sessionId) {
if (isLoading) { statusEl.textContent = '부팅 중…'; return; }
statusEl.textContent = '세션 없음';
return;
}
function showError(msg) {
loadingEl.style.display = 'none';
errEl.style.display = 'flex';
errTextEl.textContent = msg;
statusEl.textContent = '오류';
}
// ── 관리 콘솔 (telnet-style line protocol over our own WS proxy) ──
// xterm handles the OUTPUT side fine (verified: server responses render
// correctly via .write()), but its own keyboard capture never reliably
// grabbed focus once nested this deep (code.html iframe > this page > the
// terminal's own hidden textarea) — real typing produced nothing even
// after forcing focus() and confirming document.hasFocus()/activeElement
// were correct. Since this console is a line-based command protocol
// anyway (not a raw interactive shell), a plain <input> + Enter-to-send
// sidesteps the whole focus problem: browsers focus regular inputs
// reliably regardless of iframe nesting, and xterm stays as a read-only
// scrollback display.
let consoleWs = null;
let consoleTerm = null;
let activeTab = 'mgmt';
function toggleConsole() {
const opening = consolePanelEl.style.display !== 'flex';
consolePanelEl.style.display = opening ? 'flex' : 'none';
consoleBtnEl.classList.toggle('active', opening);
if (opening) {
if (!consoleWs) connectConsole();
if (!shellTerm) initShellTerm();
setTimeout(() => (activeTab === 'mgmt' ? consoleInputEl : shellInputEl).focus(), 0);
}
}
function connectConsole() {
if (!consoleTerm) {
consoleTerm = new Terminal({ convertEol: true, fontSize: 13, theme: { background: '#000' }, disableStdin: true });
consoleTerm.open(consoleTermEl);
consoleTerm.writeln('Connecting to emulator console...');
}
const wsScheme = location.protocol === 'https:' ? 'wss' : 'ws';
consoleWs = new WebSocket(wsScheme + '://' + location.host + '/api/android/' + sessionId + '/console');
consoleWs.onmessage = (ev) => consoleTerm.write(ev.data);
consoleWs.onclose = () => { consoleTerm.writeln('\r\n[연결 종료]'); consoleWs = null; };
consoleWs.onerror = () => { consoleTerm.writeln('\r\n[연결 오류]'); };
}
consoleBtnEl.addEventListener('click', () => toggleConsole());
consoleInputEl.addEventListener('keydown', (e) => {
if (e.key !== 'Enter') return;
const line = consoleInputEl.value;
if (!line) return;
if (consoleWs && consoleWs.readyState === WebSocket.OPEN) {
consoleTerm.writeln('> ' + line);
consoleWs.send(line + '\n');
consoleInputEl.value = '';
} else {
consoleTerm.writeln('[연결 안 됨]');
}
});
// ── ADB 쉘 ── one-shot `adb -s <adbPort> shell <cmd>` per line (see comment
// on /api/android/:id/shell server-side — no persistent session/state).
let shellTerm = null;
function initShellTerm() {
shellTerm = new Terminal({ convertEol: true, fontSize: 13, theme: { background: '#000' }, disableStdin: true });
shellTerm.open(shellTermEl);
shellTerm.writeln('adb shell ready — one command per line, no persistent cwd/state (chain with && if needed).');
}
shellInputEl.addEventListener('keydown', (e) => {
if (e.key !== 'Enter') return;
const cmd = shellInputEl.value;
if (!cmd) return;
shellTerm.writeln('$ ' + cmd);
shellInputEl.value = '';
shellInputEl.disabled = true;
fetch('/api/android/' + sessionId + '/shell', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cmd }),
}).then(r => r.json()).then(d => {
if (d.error) shellTerm.writeln('[오류] ' + d.error);
if (d.output) shellTerm.write(d.output.replace(/\n/g, '\r\n'));
}).catch(e => shellTerm.writeln('[연결 오류] ' + e.message))
.finally(() => { shellInputEl.disabled = false; shellInputEl.focus(); });
});
// Tab switching between the two console panes.
document.querySelectorAll('.console-tab').forEach(tabEl => {
tabEl.addEventListener('click', () => {
const tab = tabEl.dataset.tab;
activeTab = tab;
document.querySelectorAll('.console-tab').forEach(t => t.classList.toggle('active', t === tabEl));
document.getElementById('pane-mgmt').classList.toggle('active', tab === 'mgmt');
document.getElementById('pane-shell').classList.toggle('active', tab === 'shell');
(tab === 'mgmt' ? consoleInputEl : shellInputEl).focus();
});
});
// Side-panel resize drag (mirrors the bottom-panel drag pattern used
// elsewhere in the app, just horizontal instead of vertical).
(function(){
let startX = 0, startW = 0;
consoleDragEl.addEventListener('mousedown', e => {
startX = e.clientX; startW = consolePanelEl.offsetWidth; e.preventDefault();
frameEl.style.pointerEvents = 'none';
const onMove = e2 => {
const dx = startX - e2.clientX;
consolePanelEl.style.width = Math.max(280, Math.min(window.innerWidth * 0.7, startW + dx)) + 'px';
};
const onUp = () => {
document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp);
frameEl.style.pointerEvents = '';
};
document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp);
});
})();
function showReady(vncPort, adbPort) {
statusEl.textContent = '실행 중';
const host = location.hostname;
const port = location.port || (location.protocol === 'https:' ? 443 : 80);
// This image's vnc_lite.html only recognizes host/port/path/view_only/scale
// (checked via its own readQueryVariable() calls) — encrypt/autoconnect
// aren't read at all (it always connects immediately), and the scaling
// flag is named `scale` (boolean), not `resize=scale` — that was silently
// ignored, leaving rfb.scaleViewport at its false default and the phone
// screen rendering at native (small) size inside the much larger panel.
const qs = 'host=' + encodeURIComponent(host) +
'&port=' + encodeURIComponent(port) +
'&path=' + encodeURIComponent('api/android/' + sessionId + '/vnc') +
'&scale=true';
frameEl.src = '/api/android/' + sessionId + '/app/vnc_lite.html?' + qs;
frameEl.style.display = 'block';
loadingEl.style.display = 'none';
adbCmdEl.textContent = 'adb connect localhost:' + adbPort;
adbHintEl.style.display = 'inline';
zoomCtlEl.style.display = 'flex';
consoleBtnEl.style.display = 'inline-block';
}
adbCmdEl.addEventListener('click', () => {
navigator.clipboard?.writeText(adbCmdEl.textContent).catch(() => {});
});
let pollTimer = null;
const deadline = Date.now() + 95000;
function poll() {
fetch('/api/android/' + sessionId + '/status', { credentials: 'include' })
.then(r => r.json())
.then(d => {
if (d.error) { showError(d.error); return; }
if (d.ready) { showReady(d.vncPort, d.adbPort); return; }
if (Date.now() > deadline) { showError('부팅 시간 초과'); return; }
pollTimer = setTimeout(poll, 2000);
})
.catch(() => {
if (Date.now() > deadline) { showError('연결 실패'); return; }
pollTimer = setTimeout(poll, 2000);
});
}
poll();
// Deliberately no unload-triggered DELETE here anymore — a page reload or
// tab close unloads this iframe too, which was silently killing the
// session (including installed apps' in-progress state) every time the
// parent code-editor page reloaded. Sessions only end via the HARD_TTL_MS
// backstop or an explicit reset/delete call.
})();
</script>
</body>
</html>