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
+58 -6
View File
@@ -1,7 +1,14 @@
import path from 'path';
import fs from 'fs';
import { ToolResult } from '../types.js';
import { getConfig } from '../config/config.js';
import { getVault } from '../security/vault.js';
import { KakaoNotify } from '../gateway/kakao-notify.js';
import { KakaoNotify, registerKakaoImageToken } from '../gateway/kakao-notify.js';
const KAKAO_IMAGE_EXTS: Record<string, string> = {
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
'.gif': 'image/gif', '.webp': 'image/webp',
};
function getKakaoNotify(): KakaoNotify | null {
const cm = getConfig();
@@ -20,25 +27,44 @@ function getKakaoNotify(): KakaoNotify | null {
});
}
function getKakaoPublicBaseUrl(): string {
const cm = getConfig();
const vault = getVault(cm.getConfigDir());
return vault.get('channels.kakao.publicBaseUrl', 'kakao:init')?.expose() || '';
}
/** image_path를 워크스페이스 안으로 안전하게 해석. 워크스페이스 밖이면 null. */
function resolveWorkspaceImage(imagePath: string, workspacePath: string): string | null {
const abs = path.normalize(path.resolve(workspacePath, imagePath));
const root = path.normalize(workspacePath);
if (!(abs === root || abs.startsWith(root + path.sep))) return null;
if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) return null;
if (!KAKAO_IMAGE_EXTS[path.extname(abs).toLowerCase()]) return null;
return abs;
}
// ── kakao_send_message ──────────────────────────────────────────────────────
export const kakaoSendTool = {
name: 'kakao_send_message',
description: '카카오톡 "나에게 보내기"로 텍스트를 본인 카카오톡에 전송합니다. 차트 링크, 요약, 알림 등을 카카오톡으로 보내달라는 요청에 사용. 이미지 첨부는 지원하지 않으므로 링크/텍스트로만 전송합니다.',
description: '카카오톡 "나에게 보내기"로 텍스트 또는 이미지를 본인 카카오톡에 전송합니다. 차트 링크, 요약, 알림, 스크린샷 등을 보내달라는 요청에 사용. image_path 지정 시 이미지를 사진 메시지로 전송(text는 설명/캡션이 됨). PNG/JPG/GIF/WEBP만 지원 — PDF·문서·압축파일 등 다른 첨부파일 종류는 카카오 API 자체가 지원하지 않음.',
schema: {
text: '보낼 메시지 내용 (URL은 텍스트에 그대로 포함)',
text: '보낼 메시지 내용, 또는 이미지 첨부 시 설명/캡션 (URL은 텍스트에 그대로 포함)',
image_path: '(선택) 워크스페이스 내 이미지 파일 경로 — 반드시 이전 도구 결과가 반환한 정확한 경로 사용. PNG/JPG/GIF/WEBP만 지원.',
},
jsonSchema: {
type: 'object',
properties: {
text: { type: 'string', description: '보낼 메시지 (URL 포함 가능, 최대 2000자 단위로 자동 분할)' },
text: { type: 'string', description: '보낼 메시지 (URL 포함 가능, 최대 2000자 단위로 자동 분할). image_path와 함께 쓰면 이미지 설명/캡션.' },
image_path: { type: 'string', description: '워크스페이스 내 이미지 파일 경로 (PNG/JPG/GIF/WEBP). 다른 도구가 반환한 정확한 경로를 그대로 사용할 것.' },
},
required: ['text'],
required: [],
additionalProperties: false,
},
execute: async (args: any): Promise<ToolResult> => {
const text = String(args?.text || '').trim();
if (!text) return { success: false, error: 'text(보낼 내용)가 필요합니다.' };
const imagePath = String(args?.image_path || '').trim();
if (!text && !imagePath) return { success: false, error: 'text 또는 image_path 중 하나는 필요합니다.' };
const notifier = getKakaoNotify();
if (!notifier) {
@@ -48,6 +74,32 @@ export const kakaoSendTool = {
};
}
if (imagePath) {
const workspacePath = String(args?._workspacePath || args?._workspace || '');
const absPath = workspacePath ? resolveWorkspaceImage(imagePath, workspacePath) : null;
if (!absPath) {
return { success: false, error: `이미지 파일을 찾을 수 없습니다: ${imagePath} (PNG/JPG/GIF/WEBP만 지원, 워크스페이스 내 경로여야 함)` };
}
const baseUrl = getKakaoPublicBaseUrl();
if (!baseUrl) {
return {
success: false,
error: '카카오 이미지 전송에 필요한 공개 서버 주소가 없습니다. /api/kakao/auth/start 로 카카오 로그인을 브라우저에서 한 번 완료하면 자동으로 설정됩니다.',
};
}
const token = registerKakaoImageToken(absPath);
const imageUrl = `${baseUrl}/api/kakao/img/${token}`;
try {
await notifier.sendImage(imageUrl, { title: text ? text.slice(0, 50) : '📷 이미지', description: text });
return {
success: true,
stdout: `✅ 카카오톡(나에게 보내기)으로 이미지 전송 완료.${text ? `\n캡션: ${text.slice(0, 200)}` : ''}`,
};
} catch (err: any) {
return { success: false, error: `카카오톡 이미지 전송 실패: ${err.message}` };
}
}
try {
await notifier.send(text);
return {