diff --git a/agent-renderer.js b/agent-renderer.js new file mode 100644 index 0000000..862b1c2 --- /dev/null +++ b/agent-renderer.js @@ -0,0 +1,75 @@ +// agent-renderer.js — 브라우저 환경에서 실행됨 → WebRTC 사용 가능 + +let peerConnection = null; +let socket = null; +let currentStream = null; + +// 메인 프로세스로부터 명령 수신 (IPC) +console.log('electronAPI:', window.electronAPI); +console.log('desktopCapturer:', window.desktopCapturer); +window.electronAPI.receive('start-webrtc', async (payload) => { + try { + const { offer, displayId, signalingServer, employeeId } = payload; + console.log('start webrtc'); + // 1. Socket 연결 (렌더러에서도 가능) + socket = io(signalingServer); + + // 2. 화면 스트림 캡처 + const sources = await window.desktopCapturer.getSources({ types: ['screen'] }); + const source = sources.find(s => s.id === displayId); + if (!source) throw new Error('디스플레이 없음'); + + const stream = await navigator.mediaDevices.getUserMedia({ + audio: false, + video: { + mandatory: { + chromeMediaSource: 'desktop', + chromeMediaSourceId: source.id, + minWidth: 1280, + minHeight: 720 + } + } + }); + currentStream = stream; + + // 3. WebRTC 연결 + peerConnection = new RTCPeerConnection({ + iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] + }); + + stream.getTracks().forEach(track => peerConnection.addTrack(track, stream)); + + await peerConnection.setRemoteDescription(new RTCSessionDescription(offer)); + const answer = await peerConnection.createAnswer(); + await peerConnection.setLocalDescription(answer); + + // 4. Answer 전송 + socket.emit('webrtcSignal', { + targetId: employeeId, + answer + }); + + // 5. ICE candidate + peerConnection.onicecandidate = (e) => { + if (e.candidate) { + socket.emit('webrtcSignal', { + targetId: employeeId, + type: 'icecandidate', candidate: e.candidate + }); + } + }; + + // 6. 입력 이벤트 수신 (robotjs는 메인 프로세스에서 실행 권장) + socket.on('inputEvent', (data) => { + window.electronApi.send('input-event-from-renderer', data); + }); + + console.log('✅ WebRTC 시작됨 (렌더러)'); + } catch (err) { + console.error('❌ WebRTC 실패:', err); + window.electronApi.send('webrtc-error', err.message); + } +}); + +// desktopCapturer 사용을 위해 preload 필요 +// → 다음 단계에서 preload.js 설정 \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 0000000..7942efb --- /dev/null +++ b/index.html @@ -0,0 +1,11 @@ + + + + Remote Agent Renderer + + + + + + + \ No newline at end of file diff --git a/jsconfig.json b/jsconfig.json new file mode 100644 index 0000000..4aafc5f --- /dev/null +++ b/jsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "es5", + "module": "esnext", + "baseUrl": "./", + "moduleResolution": "node", + "paths": { + "@/*": [ + "src/*" + ] + }, + "lib": [ + "esnext", + "dom", + "dom.iterable", + "scripthost" + ] + } +} diff --git a/main.js b/main.js index 95930b4..ab5c5a5 100644 --- a/main.js +++ b/main.js @@ -1,39 +1,40 @@ // main.js -const { app, BrowserWindow, Tray, Menu, nativeImage, ipcMain } = require('electron'); +const { app, BrowserWindow, Tray, Menu, nativeImage, dialog, ipcMain } = require('electron'); const path = require('path'); const io = require('socket.io-client'); -const os = require('os'); +const { desktopCapturer } = require('electron'); -// 고유 직원 ID (실제로는 설정 파일, 환경변수, 또는 도메인 계정 연동 권장) +// 고유 직원 ID const EMPLOYEE_ID = "psn14020"; // 시그널링 서버 주소 -const SIGNALING_SERVER = 'http://localhost:3001'; // 실제 서버로 변경 +const SIGNALING_SERVER = 'http://localhost:3001'; let tray = null; let mainWindow = null; let socket = null; let peerConnection = null; -// 자동 시작 설정 (부팅 시 실행) +// 자동 시작 설정 app.setLoginItemSettings({ openAtLogin: true, openAsHidden: true }); -// 백그라운드 창 (화면 공유용, 눈에 보이지 않음) +// 백그라운드 창 (화면 공유용) function createHiddenWindow() { - mainWindow = new BrowserWindow({ - show: false, + const win = new BrowserWindow({ + show: true, webPreferences: { - nodeIntegration: false, contextIsolation: true, preload: path.join(__dirname, 'preload.js') } }); + win.loadFile('index.html'); + return win; } -// 트레이 아이콘 (선택 사항, 고령 사용자 친화적) +// 트레이 아이콘 function createTray() { const iconPath = path.join(__dirname, 'resources', 'icon.png'); const icon = nativeImage.createFromPath(iconPath); @@ -46,11 +47,122 @@ function createTray() { tray.setContextMenu(contextMenu); } -// WebRTC 연결 준비 -function setupWebRTC() { - // 실제 구현 시 RTCPeerConnection 생성, 스트림 처리 등 포함 - // 여기선 간략히 placeholder - console.log('WebRTC 준비됨'); +// 🖥️ 사용 가능한 디스플레이 목록 전송 +async function sendAvailableDisplays() { + try { + const sources = await desktopCapturer.getSources({ types: ['screen'] }); + const displays = sources.map(source => ({ + id: source.id, + name: source.name, + width: source.thumbnail.getSize().width, + height: source.thumbnail.getSize().height + })); + socket.emit('availableDisplays', { displays }); + } catch (err) { + console.error('디스플레이 목록 가져오기 실패:', err); + } +} + +// 🔁 WebRTC 연결 설정 (offer 수신 후 호출) +async function setupWebRTCConnection(offer, requestedDisplayId) { + try { + // 기존 연결 정리 + if (peerConnection) { + peerConnection.close(); + } + + peerConnection = new RTCPeerConnection({ + iceServers: [ + { urls: 'stun:stun.l.google.com:19302' } + // 프로덕션: TURN 서버 추가 권장 + ] + }); + + // 🖼️ 화면 스트림 캡처 + const sources = await desktopCapturer.getSources({ types: ['screen'] }); + const source = sources.find(s => s.id === requestedDisplayId); + if (!source) throw new Error(`디스플레이 ${requestedDisplayId}를 찾을 수 없습니다.`); + + const stream = await navigator.mediaDevices.getUserMedia({ + audio: false, + video: { + mandatory: { + chromeMediaSource: 'desktop', + chromeMediaSourceId: source.id, + minWidth: 1280, + minHeight: 720, + maxWidth: 1920, + maxHeight: 1080 + } + } + }); + + // 스트림을 PeerConnection에 추가 + stream.getTracks().forEach(track => { + peerConnection.addTrack(track, stream); + }); + + // Offer 설정 + await peerConnection.setRemoteDescription(new RTCSessionDescription(offer)); + + // ✅ Answer SDP 생성 + const answer = await peerConnection.createAnswer(); + await peerConnection.setLocalDescription(answer); + + // Answer 전송 + socket.emit('webrtcSignal', { + targetId: EMPLOYEE_ID, + answer + }); + + // ICE candidate 전송 + peerConnection.onicecandidate = (event) => { + if (event.candidate) { + socket.emit('webrtcSignal', { + targetId: EMPLOYEE_ID, + type: 'icecandidate', + candidate: event.candidate + }); + } + }; + + console.log('✅ WebRTC 연결 설정 완료. 화면 공유 시작됨.'); + } catch (err) { + console.error('❌ WebRTC 설정 실패:', err); + dialog.showErrorBox('오류', '화면 공유 시작에 실패했습니다:\n' + err.message); + } +} + +// 🖱️ 입력 이벤트 처리 (robotjs 필요) +function setupInputHandler() { + // robotjs는 별도 설치 및 권한 필요 + let robot; + try { + robot = require('robotjs'); + } catch (e) { + console.warn('robotjs 미설치 또는 로드 실패. 입력 제어 불가.'); + return; + } + + socket.on('inputEvent', ({ type, ...data }) => { + try { + if (type === 'mouse') { + const { x, y, action } = data; + robot.moveMouse(Math.round(x), Math.round(y)); + if (action === 'click') { + robot.mouseClick(); + } + } else if (type === 'keyboard') { + const { key } = data; + // 간단한 키 매핑 (보안상 복잡한 키는 제한 권장) + if (key.length === 1 || ['Enter', 'Backspace', 'Tab', 'Escape'].includes(key)) { + robot.keyTap(key.toLowerCase()); + } + } + } catch (err) { + console.error('입력 이벤트 처리 오류:', err); + } + }); } // 시그널링 연결 @@ -64,11 +176,10 @@ function connectToSignaling() { socket.on('connect', () => { console.log('시그널링 서버 연결됨. 직원 등록 중...'); socket.emit('register', EMPLOYEE_ID); + sendAvailableDisplays(); // 연결 시 디스플레이 목록 전송 }); - socket.on('controlRequest', async ({ from, offer }) => { - // 🔔 사용자에게 알림 (간단한 확인 창) - const { dialog } = require('electron'); + socket.on('controlRequest', async ({ offer, displayId }) => { const result = await dialog.showMessageBox({ type: 'question', title: '원격 연결 요청', @@ -79,43 +190,73 @@ function connectToSignaling() { }); if (result.response === 0) { - // 허용 → WebRTC 연결 시작 - socket.emit('webrtcSignal', { - targetId: EMPLOYEE_ID, - data: { type: 'answer', sdp: '...' } // 실제 SDP 생성 필요 + // ✅ 렌더러 프로세스에 WebRTC 시작 명령 전달 + rendererWindow.webContents.send('start-webrtc', { + offer, + displayId, + signalingServer: SIGNALING_SERVER, + employeeId: EMPLOYEE_ID }); - setupWebRTC(); } else { socket.emit('webrtcSignal', { targetId: EMPLOYEE_ID, data: { type: 'reject' } }); } }); - socket.on('webrtcSignal', ({ data }) => { - // ICE candidate, answer 등 처리 - console.log('WebRTC 신호 수신:', data); + // WebRTC 신호 수신 (ICE candidate 등) + socket.on('webrtcSignal', async ({ data }) => { + if (!peerConnection) return; + try { + if (data.type === 'icecandidate' && data.candidate) { + await peerConnection.addIceCandidate(new RTCIceCandidate(data.candidate)); + } + } catch (err) { + console.error('ICE candidate 처리 오류:', err); + } }); socket.on('disconnect', () => { console.log('시그널링 서버와 연결 끊김'); + if (peerConnection) { + peerConnection.close(); + peerConnection = null; + } }); socket.on('error', (err) => { console.error('소켓 오류:', err); }); -} + // 입력 이벤트 리스너 설정 + setupInputHandler(); +} +// 앱 준비 시 +let rendererWindow = null; // 앱 준비 완료 app.whenReady().then(() => { - createHiddenWindow(); + rendererWindow = createHiddenWindow(); createTray(); connectToSignaling(); app.on('activate', () => { - // macOS에서 dock 클릭 시 동작 (필요 시 창 표시) + // macOS 대응 }); }); -// 모든 창 닫힘 (Windows/Linux) app.on('window-all-closed', () => { - // 앱 종료하지 않고 백그라운드 유지 -}); \ No newline at end of file + // Windows/Linux에서 종료하지 않음 +}); + +ipcMain.on('input-event-from-renderer', (event, data) => { + // 여기서 robotjs로 입력 실행 (메인 프로세스에서만 가능) + try { + const robot = require('robotjs'); + if (data.type === 'mouse') { + robot.moveMouse(Math.round(data.x), Math.round(data.y)); + if (data.action === 'click') robot.mouseClick(); + } else if (data.type === 'keyboard') { + robot.keyTap(data.key); + } + } catch (e) { + console.error('robotjs 오류:', e); + } +}) \ No newline at end of file diff --git a/preload.js b/preload.js index 948ad84..1d36087 100644 --- a/preload.js +++ b/preload.js @@ -3,4 +3,21 @@ const { contextBridge, ipcRenderer } = require('electron'); contextBridge.exposeInMainWorld('agentAPI', { getEmployeeId: () => ipcRenderer.invoke('get-employee-id') +}); +// preload.js + +contextBridge.exposeInMainWorld('electronAPI', { + // 메인 → 렌더러 메시지 수신 + receive: (channel, func) => { + ipcRenderer.on(channel, (event, ...args) => func(...args)); + }, + // 렌더러 → 메인 메시지 전송 + send: (channel, data) => { + ipcRenderer.send(channel, data); + } +}); + +// desktopCapturer 노출 (보안 주의: 신뢰 가능한 코드만) +contextBridge.exposeInMainWorld('desktopCapturer', { + getSources: (options) => require('electron').desktopCapturer.getSources(options) }); \ No newline at end of file