v4.0.0: 라이브 서비스 /srv/homeclaw로 이전 + Android 실기기 USB 연동 + 관리자 IP 차단 뷰어

- homeclaw 라이브 서비스를 /home/kim/homeclaw에서 /srv/homeclaw(480G 전용 파티션)로
  컷오버, 하드코딩된 절대경로 전부 치환 (config, mcp-servers, routes, scripts, web-ui)
- Android 에뮬레이터 컨테이너 리소스를 32스레드 Xeon 기준으로 상향(3g/3cpu → 6g/10cpu)
- 코드 에디터에 USB 실기기 패널 추가: 기기 감지, APK 설치, Logcat, scrcpy 화면 미러링
  (companion.py에 독립 프로세스로 scrcpy 스폰하는 로직 포함)
- 관리자 컨텍스트 뷰어에 차단된 IP 목록 탭 추가
- ollama primary 모델을 gemma4:31b-cloud로, 음성 TTS를 edge_tts로 전환

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
kim
2026-07-10 13:31:28 +09:00
co-authored by Claude Sonnet 5
parent 34fac14b20
commit f014fec7e2
17 changed files with 380 additions and 265 deletions
+204 -3
View File
@@ -642,7 +642,192 @@ function updateCompanionStatus(connected) {
if (lsBtn) lsBtn.style.color = connected ? '#22c55e' : '#6b7280';
}
// reqId로 상관관계를 맺는 companion.py 요청/응답 헬퍼 (claude-app.js의 동일 함수 포팅)
function companionRequest(type, payload, responseType, timeoutMs = 15000) {
return new Promise((resolve, reject) => {
if (!companionWs || companionWs.readyState !== WebSocket.OPEN) { reject(new Error('companion.py가 연결되지 않았습니다')); return; }
const reqId = type + '_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6);
const timeout = setTimeout(() => { companionWs.removeEventListener('message', handler); reject(new Error('시간 초과')); }, timeoutMs);
const handler = (evt) => {
let d; try { d = JSON.parse(evt.data); } catch { return; }
if (d.reqId === reqId && d.type === responseType) {
clearTimeout(timeout);
companionWs.removeEventListener('message', handler);
resolve(d);
}
};
companionWs.addEventListener('message', handler);
companionWs.send(JSON.stringify({ type, reqId, ...payload }));
});
}
// ── 📱 USB 실기기 (companion.py를 통한 로컬 PC의 adb/scrcpy) ──────────────────
let _androidUsbOpen = false;
let _androidUsbDevices = []; // [{serial, state, model}]
function _androidUsbLog(text) {
const log = document.getElementById('android-usb-log');
if (!log) return;
const atBottom = log.scrollHeight - log.scrollTop - log.clientHeight < 30;
log.textContent += (log.textContent ? '\n' : '') + text;
if (atBottom) log.scrollTop = log.scrollHeight;
}
function _androidUsbSetStatus(text, colorBg, colorFg) {
const el = document.getElementById('android-usb-status');
if (!el) return;
el.textContent = text;
if (colorBg) el.style.background = colorBg;
if (colorFg) el.style.color = colorFg;
}
function _androidUsbSetHint(text) {
const el = document.getElementById('android-usb-hint');
if (!el) return;
if (!text) { el.style.display = 'none'; el.textContent = ''; return; }
el.style.display = '';
el.textContent = text;
}
function _androidUsbSetButtonsEnabled(enabled) {
['android-usb-install-btn', 'android-usb-logcat-btn', 'android-usb-mirror-btn'].forEach(id => {
const btn = document.getElementById(id);
if (btn) btn.disabled = !enabled;
});
}
function _androidUsbSelectedSerial() {
const sel = document.getElementById('android-usb-device-sel');
return sel && sel.value ? sel.value : (_androidUsbDevices[0]?.serial || null);
}
// companion.py가 이번 기능을 지원하지 않는 구버전일 때(browse_file/scrcpy_start 타임아웃)
// exec 계열의 일반 타임아웃과 구분되는 안내 문구를 보여준다.
function _androidUsbOutdatedCompanionMessage() {
return 'companion.py가 이 기능을 지원하지 않는 것 같습니다. 최신 버전으로 다시 다운로드해주세요 '
+ '(⬇ 다운로드 버튼, 또는 자동시작 사용자는 install_autostart.py 재실행).';
}
function toggleAndroidUsbPanel() {
const panel = document.getElementById('android-usb-panel');
const btn = document.getElementById('code-usb-android-btn');
if (!panel) return;
_androidUsbOpen = !_androidUsbOpen;
panel.style.display = _androidUsbOpen ? 'flex' : 'none';
if (btn) btn.classList.toggle('active', _androidUsbOpen);
if (monacoEditor) setTimeout(() => monacoEditor.layout(), 60);
if (_androidUsbOpen) androidUsbRefreshDevices();
}
async function androidUsbRefreshDevices() {
if (!companionConnected) {
_androidUsbSetStatus('companion 미연결', '#3a2a1a', '#e8a05a');
_androidUsbSetHint('companion.py가 실행 중이 아닙니다. 상단의 🐍 표시를 클릭해서 연결하세요.');
_androidUsbSetButtonsEnabled(false);
return;
}
_androidUsbSetStatus('확인 중...', '#1a3448', '#5aa8e8');
try {
const res = await companionRequest('exec', { command: 'adb devices -l' }, 'exec_result', 31000);
const out = res.output || '';
_androidUsbLog('$ adb devices -l\n' + out);
if (res.error) {
if (/not recognized|command not found|no such file/i.test(out)) {
_androidUsbSetStatus('adb 없음', '#3a1a1a', '#e85a5a');
_androidUsbSetHint('adb를 찾을 수 없습니다. Android SDK Platform-Tools를 설치하고 PATH에 추가해주세요.');
} else {
_androidUsbSetStatus('오류', '#3a1a1a', '#e85a5a');
_androidUsbSetHint(out);
}
_androidUsbDevices = [];
_androidUsbSetButtonsEnabled(false);
return;
}
const lines = out.split('\n').map(l => l.trim());
const startIdx = lines.findIndex(l => l.startsWith('List of devices attached'));
const deviceLines = (startIdx >= 0 ? lines.slice(startIdx + 1) : lines).filter(l => l);
_androidUsbDevices = deviceLines.map(l => {
const parts = l.split(/\s+/);
const serial = parts[0];
const state = parts[1] || 'unknown';
const modelTok = parts.find(p => p.startsWith('model:'));
const model = modelTok ? modelTok.slice(6) : '';
return { serial, state, model };
}).filter(d => d.serial);
const sel = document.getElementById('android-usb-device-sel');
if (sel) {
sel.innerHTML = _androidUsbDevices.map(d => `<option value="${d.serial}">${d.model || d.serial}${d.state !== 'device' ? ' (' + d.state + ')' : ''}</option>`).join('');
sel.style.display = _androidUsbDevices.length > 1 ? '' : 'none';
}
if (_androidUsbDevices.length === 0) {
_androidUsbSetStatus('기기 없음', '#1a3448', '#5aa8e8');
_androidUsbSetHint('USB로 연결하고 휴대폰의 설정 > 개발자 옵션에서 \'USB 디버깅\'을 켜주세요.');
_androidUsbSetButtonsEnabled(false);
} else if (_androidUsbDevices.every(d => d.state !== 'device')) {
_androidUsbSetStatus('⚠️ 인증 대기', '#3a2a1a', '#e8a05a');
_androidUsbSetHint('휴대폰 화면에 뜬 \'USB 디버깅을 허용하시겠습니까?\' 팝업에서 허용을 눌러주세요.');
_androidUsbSetButtonsEnabled(false);
} else {
_androidUsbSetStatus(`${_androidUsbDevices.length}대 연결됨`, '#1a3a2a', '#4ade80');
_androidUsbSetHint('');
_androidUsbSetButtonsEnabled(true);
}
} catch (e) {
_androidUsbSetStatus('오류', '#3a1a1a', '#e85a5a');
_androidUsbSetHint(e.message === '시간 초과' ? '시간 초과 (adb devices가 응답하지 않습니다)' : e.message);
_androidUsbSetButtonsEnabled(false);
}
}
async function androidUsbInstallApk() {
const serial = _androidUsbSelectedSerial();
try {
const picked = await companionRequest('browse_file', {
title: '설치할 APK 선택',
filetypes: [['APK 파일', '*.apk'], ['모든 파일', '*.*']],
}, 'browse_file_result', 120000);
if (picked.error) { _androidUsbLog('⚠️ ' + picked.error); return; }
if (!picked.path) { _androidUsbLog('(설치 취소됨)'); return; }
const cmd = `adb ${serial ? '-s ' + serial + ' ' : ''}install -r "${picked.path}"`;
_androidUsbLog('$ ' + cmd);
const res = await companionRequest('exec', { command: cmd }, 'exec_result', 31000);
_androidUsbLog(res.output || '');
if (/INSTALL_FAILED_/i.test(res.output || '')) _androidUsbLog('⚠️ 설치 실패 — 서명/버전 불일치일 수 있습니다.');
} catch (e) {
_androidUsbLog('⚠️ ' + (e.message === '시간 초과' ? _androidUsbOutdatedCompanionMessage() : e.message));
}
}
function androidUsbOpenLogcat() {
if (!companionConnected) { _androidUsbLog('⚠️ companion.py 미연결'); return; }
const serial = _androidUsbSelectedSerial();
const pkg = (document.getElementById('android-usb-pkg-filter')?.value || '').trim();
const id = addLocalShell('📱 Logcat');
const sFlag = serial ? `-s ${serial} ` : '';
let cmd = `adb ${sFlag}logcat -c && adb ${sFlag}logcat -v time`;
if (pkg) cmd += companionPlatform === 'windows' ? ` | findstr /I "${pkg}"` : ` | grep -i "${pkg}"`;
setTimeout(() => {
if (companionConnected && companionWs) companionWs.send(JSON.stringify({ type: 'input', shellId: id, data: cmd + '\r' }));
}, 1300);
}
async function androidUsbStartMirror() {
const serial = _androidUsbSelectedSerial();
_androidUsbLog('🪞 화면 미러링 시작 중...');
try {
const res = await companionRequest('scrcpy_start', { serial }, 'scrcpy_result', 8000);
if (res.ok) _androidUsbLog('🪞 scrcpy 실행됨 (기기 화면 창이 곧 나타납니다)');
else _androidUsbLog('⚠️ ' + res.error);
} catch (e) {
_androidUsbLog('⚠️ ' + (e.message === '시간 초과' ? _androidUsbOutdatedCompanionMessage() : e.message));
}
}
function handleCompanionMessage(msg) {
// 순수 요청/응답 타입(companionRequest로 처리됨) — 터미널 패널을 강제로 열지 않는다
if (msg.type === 'browse_file_result' || msg.type === 'scrcpy_result') return;
// shell_* messages → route by msg.shellId; run messages → active code terminal
const isShellMsg = msg.type === 'shell_output' || msg.type === 'shell_ready' || msg.type === 'shell_exit';
const targetId = isShellMsg && msg.shellId ? msg.shellId : activeCodeTermShellId;
@@ -1209,7 +1394,7 @@ function renderCodeTermTabs() {
const bar = document.getElementById('code-term-tabs'); if (!bar) return;
bar.innerHTML = codeTermShells.map(s => {
const a = s.id === activeCodeTermShellId;
const label = s.local ? '🖥️ 로컬' : 'bash';
const label = s.local ? (s.name || '🖥️ 로컬') : 'bash';
return `<div style="display:flex;align-items:center;gap:5px;padding:0 10px;cursor:pointer;font-family:'IBM Plex Mono',monospace;font-size:12px;color:${a?'#c8e6c0':'#3d6b4f'};background:${a?'#0c0c0c':'transparent'};border-top:${a?'2px solid #22c55e':'2px solid transparent'};white-space:nowrap;user-select:none" onclick="switchCodeTermShell('${s.id}')">
<svg width="10" height="10" viewBox="0 0 16 16" fill="currentColor" style="opacity:.7"><path d="M14 3v10H2V3h12zM1 2v12h14V2H1zm5 9l3-2.5L6 6v5z"/></svg>
${label}${codeTermShells.length>1?` <span onclick="closeCodeTermShell('${s.id}',event)" style="color:#3d6b4f;margin-left:3px">&times;</span>`:''}
@@ -1217,13 +1402,13 @@ function renderCodeTermTabs() {
}).join('');
}
function addLocalShell() {
function addLocalShell(label) {
if (!companionConnected) {
alert('companion.py가 실행되지 않았습니다.\n\n터미널에서: python companion.py');
return;
}
const id = 'cdt_' + Math.random().toString(36).slice(2, 8);
codeTermShells.push({ id, name: '로컬', local: true, ptyReady: false, term: null, fitAddon: null });
codeTermShells.push({ id, name: label || '🖥️ 로컬', local: true, ptyReady: false, term: null, fitAddon: null });
localShellIds.add(id);
if (!codeTermOpen) toggleCodeTerminal();
switchCodeTermShell(id);
@@ -1237,6 +1422,7 @@ function addLocalShell() {
setTimeout(() => { if (companionConnected && companionWs) companionWs.send(JSON.stringify({ type: 'input', shellId: id, data: companionPlatform === 'windows' ? 'cls\r' : 'clear\r' })); }, 800);
}
}, 300);
return id;
}
function addCodeTermShell() {
const id = 'cdt_' + Math.random().toString(36).slice(2,8);
@@ -6600,6 +6786,21 @@ document.addEventListener('DOMContentLoaded', function() {
});
});
// USB 실기기 panel resize drag
document.addEventListener('DOMContentLoaded', function() {
const drag = document.getElementById('android-usb-drag');
const panel = document.getElementById('android-usb-panel');
if (!drag || !panel) return;
let startY=0, startH=0;
drag.addEventListener('mousedown', e => {
startY=e.clientY; startH=panel.offsetHeight; e.preventDefault();
const onMove = e2 => { const dy=startY-e2.clientY; panel.style.height=Math.max(80,Math.min(window.innerHeight*.6,startH+dy))+'px'; };
const onUp = () => { document.removeEventListener('mousemove',onMove); document.removeEventListener('mouseup',onUp); drag.style.background=''; };
drag.addEventListener('mouseover', ()=>drag.style.background='rgba(90,168,232,.4)');
document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp);
});
});
// Here we run them once the DOM is ready since this page IS the code view.
(function bootCodePage() {
async function boot() {