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