v3.4.0: 서버사이드 Android 에뮬레이터 (코드 에디터 내장)

Docker(budtmo/docker-android) + noVNC로 코드 에디터 하단 패널에 실제
조작 가능한 Android 에뮬레이터 화면을 띄운다. 세션 생명주기(생성/유휴
타임아웃/정리)는 기존 Arduino QEMU 라우트 패턴을 따르고, noVNC 화면은
Stirling-PDF 프록시와 같은 방식으로 게이트웨이를 통해 리버스 프록시한다.

- src/gateway/routes-android.ts: Docker 세션 관리 + noVNC HTTP/WS 프록시
- web-ui/android-emulator.html: 부팅 폴링 + noVNC 임베드 페이지
- code.js/code.html: "🤖 Android" 버튼, 기존 에뮬레이터 패널 재사용

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
kim
2026-07-08 00:41:54 +09:00
co-authored by Claude Sonnet 5
parent 186dc4cb23
commit 94caf1c4da
9 changed files with 556 additions and 19 deletions
+104
View File
@@ -0,0 +1,104 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<title>Android 에뮬레이터</title>
<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}
#stage{flex:1;min-height:0;position:relative}
#vnc-frame{position:absolute;inset:0;width:100%;height:100%;border:none;background:#000}
#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}
</style>
</head>
<body>
<div id="wrap">
<div id="bar">
<span id="status">준비 중…</span>
<span style="flex:1"></span>
<span id="adbHint" style="display:none">adb: <code id="adbCmd" title="클릭해서 복사"></code></span>
</div>
<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>
<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');
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 = '오류';
}
function showReady(vncPort, adbPort) {
statusEl.textContent = '실행 중';
const host = location.hostname;
const port = location.port || (location.protocol === 'https:' ? 443 : 80);
const wsScheme = location.protocol === 'https:' ? 'wss' : 'ws';
const qs = 'host=' + encodeURIComponent(host) +
'&port=' + encodeURIComponent(port) +
'&path=' + encodeURIComponent('api/android/' + sessionId + '/vnc') +
'&encrypt=' + (wsScheme === 'wss' ? '1' : '0') +
'&autoconnect=true&resize=scale';
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';
}
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();
window.addEventListener('unload', () => {
fetch('/api/android/' + sessionId, { method: 'DELETE', credentials: 'include', keepalive: true }).catch(() => {});
});
})();
</script>
</body>
</html>