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>
105 lines
4.3 KiB
HTML
105 lines
4.3 KiB
HTML
<!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>
|