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>
+1 -1
View File
@@ -331,7 +331,7 @@
<button id="code-emu-btn" class="csb-term-btn" onclick="openArduinoEmulator()" title="Arduino 에뮬레이터 열기">아두이노</button>
<button id="code-compile-btn" class="csb-term-btn" onclick="csbCompileOnly()" title="실제 보드용 컴파일 (오류 시 AI 채팅으로 전송)">🔨 컴파일</button>
<button id="code-upload-btn" class="csb-term-btn" onclick="csbUploadToBoard()" title="보드에 업로드 (USB 연결 필요)" style="background:var(--brand);color:#fff;border-color:var(--brand)">⚡ 업로드</button>
<button id="code-android-btn" class="csb-term-btn" onclick="openAndroidEmulator()" title="Android 에뮬레이터 열기">🤖 Android</button>
</div>
<!-- Claude App (로컬 LLM 코딩 에이전트) -->
<div class="csb-term-row">
+22
View File
@@ -1143,6 +1143,28 @@ function openArduinoEmulator() {
}
}
function openAndroidEmulator() {
const btn = document.getElementById('code-android-btn');
if (btn) { btn.textContent = '⏳ 부팅 중…'; btn.disabled = true; }
_emuPanelLoad('/android-emulator.html?loading=1', 'Android');
codeAiAppend('system', '🤖 Android 에뮬레이터 부팅 중... (최대 90초)');
api('/api/android/start', { method: 'POST' })
.then(d => {
if (btn) { btn.textContent = '🤖 Android'; btn.disabled = false; }
if (d.error) {
codeAiAppend('system', '⚠️ ' + d.error);
toggleEmuPanel();
return;
}
_emuPanelLoad('/android-emulator.html?session=' + d.id, 'Android');
codeAiAppend('system', '✅ Android 에뮬레이터 실행 중.');
}).catch(e => {
if (btn) { btn.textContent = '🤖 Android'; btn.disabled = false; }
codeAiAppend('system', '⚠️ ' + e.message);
toggleEmuPanel();
});
}
// ── Arduino 업로드 페이지 열기 ────────────────────────────────────────────────
function csbUploadToBoard() {
const file = codeFiles.find(f => f.id === activeCodeFileId);
+2 -2
View File
@@ -54,6 +54,8 @@
</div>
<div class="mode-toggle" style="margin-left:8px">
<button class="mode-btn" onclick="window.open('/investor-app.html','_blank')" title="투자 어시스턴트">📈 투자</button>
<button class="mode-btn" onclick="window.open('/weather-app.html','_blank')" title="날씨 어시스턴트">🌤️ 날씨</button>
<button class="mode-btn" onclick="window.open('/code.html','_blank')" title="코드 에디터">💻 코드</button>
<button class="mode-btn" onclick="window.open('/pptx-wizard.html','_blank')" title="슬라이드 제작">📊 슬라이드</button>
<button class="mode-btn" onclick="window.open('/language-app.html','_blank')" title="우즈베크어 학습">🇺🇿 언어</button>
@@ -61,9 +63,7 @@
<button class="mode-btn" onclick="window.open('/accountant-app.html','_blank')" title="회계 어시스턴트">📊 회계</button>
<button class="mode-btn" onclick="window.open('/mind-app.html','_blank')" title="마음 상담실">🧠 상담</button>
<button class="mode-btn" onclick="window.open('/lawyer-app.html','_blank')" title="법률 어시스턴트">⚖️ 법률</button>
<button class="mode-btn" onclick="window.open('/investor-app.html','_blank')" title="투자 어시스턴트">📈 투자</button>
<button class="mode-btn" onclick="window.open('/detective-app.html','_blank')" title="탐정 어시스턴트">🔍 탐정</button>
<button class="mode-btn" onclick="window.open('/weather-app.html','_blank')" title="날씨 어시스턴트">🌤️ 날씨</button>
<button class="mode-btn" onclick="window.open('/traffic-app.html','_blank')" title="교통 CCTV">🚦 교통</button>
<button class="mode-btn" onclick="window.open('/dental-agent.html?t='+Date.now(),'_blank')" title="치과 사전 이미지 에이전트">🦷 치과</button>
</div>