diff --git a/public/css/style.css b/public/css/style.css index c00468d..e8edf03 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -610,3 +610,72 @@ html, body { .toast.error { border-color: var(--error); } .toast.success { border-color: var(--success); } + +/* ─── View Tabs (Chat ↔ Mirror) ──────────────────────── */ +.view-tabs { + display: flex; + gap: 2px; + background: var(--bg-tertiary); + border-radius: 8px; + padding: 2px; +} + +.view-tab { + padding: 4px 12px; + background: none; + border: none; + border-radius: 6px; + color: var(--text-muted); + font-family: var(--font-sans); + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: all var(--transition-fast); + white-space: nowrap; +} + +.view-tab:hover { + color: var(--text-secondary); +} + +.view-tab.active { + background: var(--accent-primary); + color: white; + box-shadow: 0 1px 4px rgba(99, 102, 241, 0.3); +} + +/* ─── Mirror Container ───────────────────────────────── */ +.mirror-container { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-primary); + position: relative; + overflow: hidden; +} + +.mirror-container canvas { + max-width: 100%; + max-height: 100%; + object-fit: contain; + cursor: default; + outline: none; + border-radius: 4px; +} + +.mirror-container canvas:focus { + box-shadow: 0 0 0 2px var(--accent-primary); +} + +.mirror-hint { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + color: var(--text-muted); + font-size: 14px; + pointer-events: none; + animation: pulse 1.5s infinite; +} + diff --git a/public/index.html b/public/index.html index d560795..cc85e11 100644 --- a/public/index.html +++ b/public/index.html @@ -75,6 +75,10 @@
+
+ + +
@@ -96,6 +100,12 @@ + + + @@ -145,6 +155,7 @@ + diff --git a/public/js/app.js b/public/js/app.js index 3e927ec..54044a8 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -6,9 +6,11 @@ // ─── 컴포넌트 초기화 ────────────────────────────────── const sessionPanel = new SessionPanel(); const chatPanel = new ChatPanel(); + const mirrorPanel = new MirrorPanel(); let ws = null; let reconnectTimer = null; + let currentView = 'chat'; // 'chat' | 'mirror' const WS_URL = `ws://${location.host}/ws`; // ─── DOM 레퍼런스 ───────────────────────────────────── @@ -31,6 +33,12 @@ const screenshotImage = document.getElementById('screenshotImage'); const closeScreenshot = document.getElementById('closeScreenshot'); + const tabChat = document.getElementById('tabChat'); + const tabMirror = document.getElementById('tabMirror'); + const chatMessagesWrap = document.getElementById('chatMessages'); + const chatInputArea = document.querySelector('.chat-input-area'); + const mirrorHint = document.getElementById('mirrorHint'); + // ─── WebSocket 연결 ─────────────────────────────────── function connectWebSocket() { ws = new WebSocket(WS_URL); @@ -105,6 +113,22 @@ screenshotOverlay.style.display = 'flex'; break; + case 'screencast_frame': + if (msg.sessionId === sessionPanel.activeSessionId) { + mirrorPanel.updateFrame(msg.data, msg.metadata); + if (mirrorHint) mirrorHint.style.display = 'none'; + } + break; + + case 'screencast_started': + if (!msg.success) { + showToast('Screencast 시작 실패', 'error'); + } + break; + + case 'screencast_stopped': + break; + case 'error': showToast(msg.message, 'error'); break; @@ -140,6 +164,42 @@ }); }; + // ─── 미러 패널 이벤트 ───────────────────────────── + mirrorPanel.onStartScreencast = (sessionId) => { + sendWs({ type: 'start_screencast', sessionId }); + }; + + mirrorPanel.onStopScreencast = (sessionId) => { + sendWs({ type: 'stop_screencast', sessionId }); + }; + + mirrorPanel.onInputEvent = (sessionId, event) => { + sendWs({ type: 'input_event', sessionId, event }); + }; + + // ─── 탭 전환 (Chat ↔ Mirror) ──────────────────────── + function switchView(view) { + currentView = view; + + // 탭 UI + tabChat.classList.toggle('active', view === 'chat'); + tabMirror.classList.toggle('active', view === 'mirror'); + + // 채팅 영역 표시/숨기기 + chatMessagesWrap.style.display = view === 'chat' ? '' : 'none'; + if (chatInputArea) chatInputArea.style.display = view === 'chat' ? '' : 'none'; + + if (view === 'mirror') { + if (mirrorHint) mirrorHint.style.display = ''; + mirrorPanel.start(sessionPanel.activeSessionId); + } else { + mirrorPanel.stop(); + } + } + + tabChat?.addEventListener('click', () => switchView('chat')); + tabMirror?.addEventListener('click', () => switchView('mirror')); + // ─── 모달 ───────────────────────────────────────────── function showModal() { addSessionModal.style.display = 'flex'; diff --git a/public/js/mirror-panel.js b/public/js/mirror-panel.js new file mode 100644 index 0000000..0ac0d57 --- /dev/null +++ b/public/js/mirror-panel.js @@ -0,0 +1,218 @@ +/** + * Mirror Panel — Canvas 기반 실시간 UI 미러링 + * + * Screencast 프레임을 canvas에 렌더링하고 + * 마우스/키보드/스크롤 이벤트를 서버로 전달 + */ + +class MirrorPanel { + constructor() { + this.canvas = document.getElementById('mirrorCanvas'); + this.ctx = this.canvas ? this.canvas.getContext('2d') : null; + this.containerEl = document.getElementById('mirrorContainer'); + + this.active = false; + this.sessionId = null; + + // 실제 대상 페이지 크기 (metadata에서 가져옴) + this.deviceWidth = 0; + this.deviceHeight = 0; + + // 콜백 + this.onStartScreencast = null; // callback(sessionId) + this.onStopScreencast = null; // callback(sessionId) + this.onInputEvent = null; // callback(sessionId, event) + + // 이미지 로딩용 + this._img = new Image(); + this._img.onload = () => { + if (!this.ctx) return; + this.canvas.width = this._img.naturalWidth; + this.canvas.height = this._img.naturalHeight; + this.ctx.drawImage(this._img, 0, 0); + }; + + // 마우스 이벤트 쓸모링 + this._lastMouseMove = 0; + this._MOUSE_THROTTLE = 50; // ms + + this._setupEvents(); + } + + /** + * 이벤트 바인딩 + */ + _setupEvents() { + if (!this.canvas) return; + + // --- 마우스 --- + this.canvas.addEventListener('mousedown', (e) => { + if (!this.active) return; + const pos = this._toDeviceCoords(e); + this._sendMouse('mousePressed', pos, e); + }); + + this.canvas.addEventListener('mouseup', (e) => { + if (!this.active) return; + const pos = this._toDeviceCoords(e); + this._sendMouse('mouseReleased', pos, e); + }); + + this.canvas.addEventListener('mousemove', (e) => { + if (!this.active) return; + const now = Date.now(); + if (now - this._lastMouseMove < this._MOUSE_THROTTLE) return; + this._lastMouseMove = now; + const pos = this._toDeviceCoords(e); + this._sendMouse('mouseMoved', pos, e); + }); + + // --- 스크롤 --- + this.canvas.addEventListener('wheel', (e) => { + if (!this.active) return; + e.preventDefault(); + const pos = this._toDeviceCoords(e); + this._sendInput({ + type: 'scroll', + x: pos.x, + y: pos.y, + deltaX: e.deltaX, + deltaY: e.deltaY, + }); + }, { passive: false }); + + // --- 키보드 --- + this.canvas.setAttribute('tabindex', '0'); + + this.canvas.addEventListener('keydown', (e) => { + if (!this.active) return; + e.preventDefault(); + this._sendKey('keyDown', e); + // char 이벤트도 보내야 텍스트 입력이 됨 + if (e.key.length === 1) { + this._sendKey('char', e); + } + }); + + this.canvas.addEventListener('keyup', (e) => { + if (!this.active) return; + e.preventDefault(); + this._sendKey('keyUp', e); + }); + + // canvas 클릭 시 포커스 + this.canvas.addEventListener('click', () => { + this.canvas.focus(); + }); + + // 우클릭 방지 + this.canvas.addEventListener('contextmenu', (e) => e.preventDefault()); + } + + /** + * 미러링 시작 + */ + start(sessionId) { + this.sessionId = sessionId; + this.active = true; + if (this.containerEl) this.containerEl.style.display = 'flex'; + if (this.onStartScreencast) this.onStartScreencast(sessionId); + this.canvas?.focus(); + } + + /** + * 미러링 중지 + */ + stop() { + if (this.active && this.onStopScreencast) { + this.onStopScreencast(this.sessionId); + } + this.active = false; + this.sessionId = null; + if (this.containerEl) this.containerEl.style.display = 'none'; + // canvas 초기화 + if (this.ctx && this.canvas) { + this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); + } + } + + /** + * Screencast 프레임 수신 + */ + updateFrame(data, metadata) { + if (!this.active) return; + + if (metadata) { + this.deviceWidth = metadata.deviceWidth || this.deviceWidth; + this.deviceHeight = metadata.deviceHeight || this.deviceHeight; + } + + // base64 JPEG를 이미지로 로드 → canvas에 그리기 + this._img.src = `data:image/jpeg;base64,${data}`; + } + + /** + * canvas 좌표 → 디바이스(Antigravity) 좌표 변환 + */ + _toDeviceCoords(e) { + const rect = this.canvas.getBoundingClientRect(); + const scaleX = this.canvas.width / rect.width; + const scaleY = this.canvas.height / rect.height; + return { + x: Math.round((e.clientX - rect.left) * scaleX), + y: Math.round((e.clientY - rect.top) * scaleY), + }; + } + + /** + * 마우스 이벤트 전송 + */ + _sendMouse(action, pos, e) { + const buttonMap = { 0: 'left', 1: 'middle', 2: 'right' }; + this._sendInput({ + type: 'mouse', + action, + x: pos.x, + y: pos.y, + button: buttonMap[e.button] || 'left', + clickCount: action === 'mousePressed' ? 1 : 0, + modifiers: this._getModifiers(e), + }); + } + + /** + * 키보드 이벤트 전송 + */ + _sendKey(action, e) { + this._sendInput({ + type: 'key', + action, + key: e.key, + code: e.code, + text: action === 'char' ? e.key : '', + keyCode: e.keyCode, + modifiers: this._getModifiers(e), + }); + } + + /** + * 입력 이벤트 전송 (공통) + */ + _sendInput(event) { + if (this.onInputEvent && this.sessionId) { + this.onInputEvent(this.sessionId, event); + } + } + + /** + * 수정키 비트마스크 계산 (CDP 규격) + */ + _getModifiers(e) { + let m = 0; + if (e.altKey) m |= 1; + if (e.ctrlKey) m |= 2; + if (e.metaKey) m |= 4; + if (e.shiftKey) m |= 8; + return m; + } +} diff --git a/server/cdp-client.js b/server/cdp-client.js index d30573a..03f4bd5 100644 --- a/server/cdp-client.js +++ b/server/cdp-client.js @@ -25,6 +25,8 @@ class CDPClient { this.pollInterval = null; this.onChatUpdate = null; // callback(html) this.onDisconnect = null; // callback() + this.onScreencastFrame = null; // callback({data, metadata}) + this.screencastRunning = false; } /** @@ -268,7 +270,7 @@ class CDPClient { } /** - * 스크린샷 (Phase 2 미리보기용) + * 스크린샷 (미리보기용) */ async captureScreenshot() { if (!this.connected || !this.client) return null; @@ -280,6 +282,118 @@ class CDPClient { return null; } } + + // ─── Phase 2: Screencast & Remote Input ──────────────── + + /** + * 실시간 화면 스트리밍 시작 + * Page.startScreencast로 프레임을 수신하고 onScreencastFrame 콜백으로 전달 + */ + async startScreencast(options = {}) { + if (!this.connected || !this.client) return false; + if (this.screencastRunning) return true; + + const { + format = 'jpeg', + quality = 60, + maxWidth = 1280, + maxHeight = 900, + everyNthFrame = 2, + } = options; + + try { + // 프레임 이벤트 리스너 등록 + this.client.Page.screencastFrame(async (params) => { + // ACK — 이걸 보내야 다음 프레임이 옴 + try { + await this.client.Page.screencastFrameAck({ sessionId: params.sessionId }); + } catch { /* ignore */ } + + if (this.onScreencastFrame) { + this.onScreencastFrame({ + data: params.data, // base64 JPEG + metadata: params.metadata, // {offsetTop, pageScaleFactor, deviceWidth, deviceHeight, ...} + sessionId: params.sessionId, + }); + } + }); + + await this.client.Page.startScreencast({ + format, + quality, + maxWidth, + maxHeight, + everyNthFrame, + }); + + this.screencastRunning = true; + console.log(`[CDP] Screencast 시작 (${maxWidth}x${maxHeight}, q${quality}, every ${everyNthFrame}th frame)`); + return true; + } catch (err) { + console.error('[CDP] Screencast 시작 오류:', err.message); + return false; + } + } + + /** + * 실시간 화면 스트리밍 중지 + */ + async stopScreencast() { + if (!this.connected || !this.client || !this.screencastRunning) return; + try { + await this.client.Page.stopScreencast(); + } catch { /* ignore */ } + this.screencastRunning = false; + this.onScreencastFrame = null; + console.log('[CDP] Screencast 중지'); + } + + /** + * 원격 입력 이벤트 디스패치 + * @param {Object} evt — { type: 'mouse'|'key'|'scroll', ... } + */ + async dispatchInput(evt) { + if (!this.connected || !this.client) return; + + try { + switch (evt.type) { + case 'mouse': + await this.client.Input.dispatchMouseEvent({ + type: evt.action, // mousePressed, mouseReleased, mouseMoved + x: evt.x, + y: evt.y, + button: evt.button || 'left', + clickCount: evt.clickCount || 1, + modifiers: evt.modifiers || 0, + }); + break; + + case 'scroll': + await this.client.Input.dispatchMouseEvent({ + type: 'mouseWheel', + x: evt.x, + y: evt.y, + deltaX: evt.deltaX || 0, + deltaY: evt.deltaY || 0, + }); + break; + + case 'key': + await this.client.Input.dispatchKeyEvent({ + type: evt.action, // keyDown, keyUp, char + key: evt.key, + code: evt.code || '', + text: evt.text || '', + nativeVirtualKeyCode: evt.keyCode || 0, + windowsVirtualKeyCode: evt.keyCode || 0, + modifiers: evt.modifiers || 0, + }); + break; + } + } catch (err) { + // 입력 이벤트 오류는 빈번할 수 있으므로 조용히 처리 + } + } } module.exports = CDPClient; diff --git a/server/index.js b/server/index.js index 13f55d1..7083873 100644 --- a/server/index.js +++ b/server/index.js @@ -43,6 +43,9 @@ wss.on('connection', (ws) => { const state = wsClients.get(ws); if (state?.activeSessionId) { stopSessionPolling(state.activeSessionId); + // 스크린캠스트도 중지 + const session = sessionManager.getSession(state.activeSessionId); + if (session) session.client.stopScreencast(); } wsClients.delete(ws); console.log('[WS] 클라이언트 연결 해제'); @@ -126,6 +129,56 @@ async function handleWsMessage(ws, msg) { break; } + // ─── Phase 2: Screencast & Remote Input ──────────── + + case 'start_screencast': { + const session = sessionManager.getSession(msg.sessionId || state.activeSessionId); + if (!session) { + ws.send(JSON.stringify({ type: 'error', message: '활성 세션 없음' })); + return; + } + + // 스크린캠스트 프레임 콜백 등록 + session.client.onScreencastFrame = (frame) => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ + type: 'screencast_frame', + sessionId: session.id, + data: frame.data, + metadata: frame.metadata, + })); + } + }; + + const ok = await session.client.startScreencast(msg.options || {}); + ws.send(JSON.stringify({ + type: 'screencast_started', + sessionId: session.id, + success: ok, + })); + break; + } + + case 'stop_screencast': { + const session = sessionManager.getSession(msg.sessionId || state.activeSessionId); + if (session) { + await session.client.stopScreencast(); + } + ws.send(JSON.stringify({ + type: 'screencast_stopped', + sessionId: msg.sessionId || state.activeSessionId, + })); + break; + } + + case 'input_event': { + const session = sessionManager.getSession(msg.sessionId || state.activeSessionId); + if (session) { + await session.client.dispatchInput(msg.event); + } + break; + } + default: ws.send(JSON.stringify({ type: 'error', message: `알 수 없는 메시지 타입: ${msg.type}` })); }