first
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
/.idea/
|
||||||
|
/dist/
|
||||||
|
/node_modules/
|
||||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>원격 제어 관리자</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
10
frontend/src/main.jsx
Normal file
10
frontend/src/main.jsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
// frontend/src/main.jsx
|
||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import RemoteControl from './remote.jsx';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<RemoteControl />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
237
frontend/src/remote.jsx
Normal file
237
frontend/src/remote.jsx
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
// frontend/src/RemoteControl.jsx
|
||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import io from 'socket.io-client';
|
||||||
|
|
||||||
|
const SIGNALING_SERVER = 'http://localhost:3001'; // 실제 서버 주소로 변경
|
||||||
|
|
||||||
|
export default function RemoteControl() {
|
||||||
|
const [employeeId, setEmployeeId] = useState('');
|
||||||
|
const [status, setStatus] = useState('disconnected'); // 'disconnected' | 'connecting' | 'connected'
|
||||||
|
const [displays, setDisplays] = useState([]);
|
||||||
|
const [selectedDisplay, setSelectedDisplay] = useState(null);
|
||||||
|
const [isControlling, setIsControlling] = useState(false);
|
||||||
|
|
||||||
|
const videoRef = useRef(null);
|
||||||
|
const socketRef = useRef(null);
|
||||||
|
const peerConnectionRef = useRef(null);
|
||||||
|
|
||||||
|
// 🔌 소켓 연결
|
||||||
|
useEffect(() => {
|
||||||
|
const socket = io(SIGNALING_SERVER, {
|
||||||
|
reconnection: true,
|
||||||
|
reconnectionAttempts: Infinity
|
||||||
|
});
|
||||||
|
socketRef.current = socket;
|
||||||
|
|
||||||
|
socket.on('availableDisplays', ({ displays }) => {
|
||||||
|
setDisplays(displays);
|
||||||
|
if (displays.length > 0 && !selectedDisplay) {
|
||||||
|
setSelectedDisplay(displays[0].id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('webrtcSignal', async ({ data }) => {
|
||||||
|
if (!peerConnectionRef.current) return;
|
||||||
|
try {
|
||||||
|
if (data.type === 'answer') {
|
||||||
|
await peerConnectionRef.current.setRemoteDescription(new RTCSessionDescription(data));
|
||||||
|
setStatus('connected');
|
||||||
|
setIsControlling(true);
|
||||||
|
} else if (data.type === 'icecandidate' && data.candidate) {
|
||||||
|
await peerConnectionRef.current.addIceCandidate(new RTCIceCandidate(data.candidate));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('WebRTC 처리 오류:', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
socket.disconnect();
|
||||||
|
};
|
||||||
|
}, [selectedDisplay]);
|
||||||
|
|
||||||
|
// ▶️ 연결 시작
|
||||||
|
const startControl = async () => {
|
||||||
|
|
||||||
|
if (!employeeId) return;
|
||||||
|
|
||||||
|
setStatus('connecting');
|
||||||
|
const pc = new RTCPeerConnection({
|
||||||
|
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
|
||||||
|
});
|
||||||
|
peerConnectionRef.current = pc;
|
||||||
|
|
||||||
|
pc.ontrack = (event) => {
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.srcObject = event.streams[0];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
pc.onicecandidate = (event) => {
|
||||||
|
if (event.candidate && socketRef.current) {
|
||||||
|
socketRef.current.emit('webrtcSignal', {
|
||||||
|
targetId: employeeId,
|
||||||
|
type: 'icecandidate',
|
||||||
|
candidate: event.candidate
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const offer = await pc.createOffer({ offerToReceiveVideo: true });
|
||||||
|
await pc.setLocalDescription(offer);
|
||||||
|
|
||||||
|
socketRef.current.emit('requestControl', {
|
||||||
|
targetId: employeeId,
|
||||||
|
offer: offer,
|
||||||
|
displayId: selectedDisplay
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('연결 실패:', err);
|
||||||
|
setStatus('disconnected');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 🖱️ 입력 이벤트 전달
|
||||||
|
const handleMouseEvent = (e) => {
|
||||||
|
if (!isControlling || !videoRef.current) return;
|
||||||
|
|
||||||
|
const video = videoRef.current;
|
||||||
|
const rect = video.getBoundingClientRect();
|
||||||
|
const x = e.clientX - rect.left;
|
||||||
|
const y = e.clientY - rect.top;
|
||||||
|
|
||||||
|
// 비율 기반 좌표 → 실제 해상도로 변환 (직원 측에서 display 크기 알면 정확히 변환 가능)
|
||||||
|
// 여기선 예시로 1920x1080 기준
|
||||||
|
const scaleX = 1920 / video.videoWidth;
|
||||||
|
const scaleY = 1080 / video.videoHeight;
|
||||||
|
|
||||||
|
socketRef.current?.emit('inputEvent', {
|
||||||
|
targetId: employeeId,
|
||||||
|
type: 'mouse',
|
||||||
|
action: e.type === 'click' ? 'click' : 'move',
|
||||||
|
x: x * scaleX,
|
||||||
|
y: y * scaleY,
|
||||||
|
button: e.button
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e) => {
|
||||||
|
if (!isControlling) return;
|
||||||
|
socketRef.current?.emit('inputEvent', {
|
||||||
|
targetId: employeeId,
|
||||||
|
type: 'keyboard',
|
||||||
|
key: e.key,
|
||||||
|
keyCode: e.keyCode,
|
||||||
|
shiftKey: e.shiftKey,
|
||||||
|
ctrlKey: e.ctrlKey,
|
||||||
|
altKey: e.altKey
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '20px', fontFamily: 'sans-serif', maxWidth: '1200px', margin: '0 auto' }}>
|
||||||
|
<h2 style={{ fontSize: '24px' }}>🖥️ 원격 제어 관리자</h2>
|
||||||
|
|
||||||
|
{status === 'disconnected' && (
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: '16px' }}>
|
||||||
|
<label style={{ fontSize: '18px', marginRight: '10px' }}>직원 번호:</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={employeeId}
|
||||||
|
onChange={(e) => setEmployeeId(e.target.value)}
|
||||||
|
placeholder="예: EMP123"
|
||||||
|
style={{
|
||||||
|
fontSize: '18px',
|
||||||
|
padding: '10px',
|
||||||
|
width: '200px',
|
||||||
|
border: '1px solid #ccc',
|
||||||
|
borderRadius: '4px'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{displays.length > 0 && (
|
||||||
|
<div style={{ marginBottom: '16px' }}>
|
||||||
|
<label style={{ fontSize: '18px', marginRight: '10px' }}>모니터 선택:</label>
|
||||||
|
<select
|
||||||
|
value={selectedDisplay || ''}
|
||||||
|
onChange={(e) => setSelectedDisplay(e.target.value)}
|
||||||
|
style={{ fontSize: '16px', padding: '8px' }}
|
||||||
|
>
|
||||||
|
{displays.map((d) => (
|
||||||
|
<option key={d.id} value={d.id}>
|
||||||
|
{d.name} ({d.width}×{d.height})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={startControl}
|
||||||
|
disabled={!employeeId || status === 'connecting'}
|
||||||
|
style={{
|
||||||
|
fontSize: '20px',
|
||||||
|
padding: '12px 24px',
|
||||||
|
backgroundColor: '#2196F3',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '6px',
|
||||||
|
cursor: employeeId ? 'pointer' : 'not-allowed'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
원격 연결 요청
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'connecting' && (
|
||||||
|
<p style={{ fontSize: '18px', color: '#e65100' }}>직원의 승인을 기다리는 중...</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'connected' && (
|
||||||
|
<p style={{ fontSize: '18px', color: 'green' }}>✅ 연결 성공! 화면을 제어할 수 있습니다.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isControlling && (
|
||||||
|
<div
|
||||||
|
tabIndex={0}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '600px',
|
||||||
|
border: '2px solid #444',
|
||||||
|
position: 'relative',
|
||||||
|
outline: 'none',
|
||||||
|
backgroundColor: '#000'
|
||||||
|
}}
|
||||||
|
onMouseMove={handleMouseEvent}
|
||||||
|
onClick={handleMouseEvent}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
>
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
autoPlay
|
||||||
|
playsInline
|
||||||
|
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '12px',
|
||||||
|
left: '12px',
|
||||||
|
background: 'rgba(0,0,0,0.7)',
|
||||||
|
color: 'white',
|
||||||
|
padding: '6px 12px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '16px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
직원: {employeeId} | 모니터: {selectedDisplay}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
main.js
Normal file
33
main.js
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
// electron-main.js
|
||||||
|
const { app, BrowserWindow } = require('electron');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
function createWindow() {
|
||||||
|
const win = new BrowserWindow({
|
||||||
|
width: 1200,
|
||||||
|
height: 800,
|
||||||
|
webPreferences: {
|
||||||
|
preload: path.join(__dirname, 'preload.js'),
|
||||||
|
contextIsolation: true,
|
||||||
|
sandbox: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (app.isPackaged) {
|
||||||
|
win.loadFile(path.join(__dirname, 'frontend/dist/index.html'));
|
||||||
|
} else {
|
||||||
|
win.loadURL('http://localhost:5173');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.whenReady().then(() => {
|
||||||
|
createWindow();
|
||||||
|
|
||||||
|
app.on('activate', () => {
|
||||||
|
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on('window-all-closed', () => {
|
||||||
|
if (process.platform !== 'darwin') app.quit();
|
||||||
|
});
|
||||||
25
package.json
Normal file
25
package.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "client",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "concurrently \"vite\" \"electron ./main.js\"",
|
||||||
|
"build": "vite build",
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"description": "",
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
|
"socket.io-client": "^4.8.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"electron": "^38.3.0",
|
||||||
|
"vite": "^5.0.0",
|
||||||
|
"concurrently": "^8.2.0",
|
||||||
|
"@vitejs/plugin-react": "^4.2.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
7
preload.js
Normal file
7
preload.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
// preload.js
|
||||||
|
const { contextBridge } = require('electron');
|
||||||
|
|
||||||
|
// 현재는 추가 API 없음. 향후 필요 시 확장 가능
|
||||||
|
contextBridge.exposeInMainWorld('electronAPI', {
|
||||||
|
// 예: quit: () => ipcRenderer.send('quit-app')
|
||||||
|
});
|
||||||
26
vite.config.js
Normal file
26
vite.config.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
// vite.config.js
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
root: 'frontend', // Vite가 frontend 폴더를 기준으로 빌드
|
||||||
|
publicDir: 'frontend/public',
|
||||||
|
build: {
|
||||||
|
outDir: 'frontend/dist',
|
||||||
|
emptyOutDir: true,
|
||||||
|
rollupOptions: {
|
||||||
|
input: {
|
||||||
|
main: path.resolve(__dirname, 'frontend/index.html')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
strictPort: true,
|
||||||
|
host: 'localhost'
|
||||||
|
},
|
||||||
|
// Electron은 file:// 프로토콜 사용 → 상대 경로로 자산 로드
|
||||||
|
base: './'
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user