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