feat(cdp): Phase 2 - real-time UI mirroring via screencast + remote input
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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}` }));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user