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:
@@ -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
@@ -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">
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user