세션 영속화, 관리콘솔 WS 프록시(attachAndroidConsoleWsProxy), ADB 쉘 엔드포인트, 고아 컨테이너 정리 로직 추가. 2026-07-31 실사용 검증 완료된 작업을 커밋만 안 하고 남겨뒀던 것. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
293 lines
14 KiB
HTML
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>
|