feat: FunConnect v1.0.0 - Minecraft联机平台完整版
- server: Node.js TCP中继服务器,支持多节点集群 - web: React管理面板(仪表盘、房间管理、节点管理) - client: Electron桌面客户端(连接、创建/加入房间、本地代理) - deploy: Ubuntu一键部署脚本
This commit is contained in:
4478
client/package-lock.json
generated
Normal file
4478
client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
45
client/package.json
Normal file
45
client/package.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "funconnect",
|
||||
"version": "1.0.0",
|
||||
"description": "FunMC Minecraft 联机客户端",
|
||||
"main": "dist/main/index.js",
|
||||
"author": "FunMC",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"build:ts": "tsc",
|
||||
"start": "npm run build:ts && electron .",
|
||||
"dev": "tsc && electron .",
|
||||
"pack": "npm run build:ts && electron-builder --dir",
|
||||
"dist": "npm run build:ts && electron-builder"
|
||||
},
|
||||
"build": {
|
||||
"appId": "cn.funmc.connect",
|
||||
"productName": "FunConnect",
|
||||
"directories": {
|
||||
"output": "release"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*",
|
||||
"renderer/**/*",
|
||||
"package.json"
|
||||
],
|
||||
"win": {
|
||||
"target": "nsis",
|
||||
"icon": "renderer/icon.ico"
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"allowToChangeInstallationDirectory": true
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.6.2",
|
||||
"electron-store": "^8.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.10.0",
|
||||
"electron": "^28.1.0",
|
||||
"electron-builder": "^24.9.1",
|
||||
"typescript": "^5.3.2"
|
||||
}
|
||||
}
|
||||
278
client/renderer/app.js
Normal file
278
client/renderer/app.js
Normal file
@@ -0,0 +1,278 @@
|
||||
// FunConnect Client - Renderer Logic
|
||||
const $ = (sel) => document.querySelector(sel);
|
||||
const $$ = (sel) => document.querySelectorAll(sel);
|
||||
|
||||
let isConnected = false;
|
||||
let currentServerUrl = '';
|
||||
|
||||
// ===== Window Controls =====
|
||||
$('#btn-min').addEventListener('click', () => window.funmc.minimize());
|
||||
$('#btn-max').addEventListener('click', () => window.funmc.maximize());
|
||||
$('#btn-close').addEventListener('click', () => window.funmc.close());
|
||||
|
||||
// ===== Navigation =====
|
||||
$$('.nav-item').forEach(item => {
|
||||
item.addEventListener('click', () => {
|
||||
const page = item.dataset.page;
|
||||
$$('.nav-item').forEach(n => n.classList.remove('active'));
|
||||
item.classList.add('active');
|
||||
$$('.page').forEach(p => p.classList.remove('active'));
|
||||
$(`#page-${page}`).classList.add('active');
|
||||
});
|
||||
});
|
||||
|
||||
// ===== Connect Page =====
|
||||
$('#btn-connect').addEventListener('click', async () => {
|
||||
const url = $('#server-url').value.trim();
|
||||
if (!url) {
|
||||
showMsg('#connect-msg', '请输入服务器地址', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = $('#btn-connect');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner"></span> 连接中...';
|
||||
showMsg('#connect-msg', '', '');
|
||||
|
||||
const result = await window.funmc.connectServer(url);
|
||||
|
||||
if (result.success) {
|
||||
isConnected = true;
|
||||
currentServerUrl = url;
|
||||
updateServerStatus(true);
|
||||
showMsg('#connect-msg', '连接成功!', 'success');
|
||||
showServerInfo(result.data);
|
||||
} else {
|
||||
isConnected = false;
|
||||
updateServerStatus(false);
|
||||
showMsg('#connect-msg', '连接失败: ' + result.error, 'error');
|
||||
}
|
||||
|
||||
btn.disabled = false;
|
||||
btn.textContent = '连接服务器';
|
||||
});
|
||||
|
||||
function showServerInfo(data) {
|
||||
const grid = $('#server-info-grid');
|
||||
grid.innerHTML = '';
|
||||
const items = [
|
||||
{ label: '节点名称', value: data.nodeName || 'N/A' },
|
||||
{ label: '节点ID', value: data.nodeId || 'N/A' },
|
||||
{ label: '运行模式', value: data.isMaster ? '主节点' : '工作节点' },
|
||||
{ label: '运行状态', value: data.status === 'ok' ? '正常' : '异常' },
|
||||
{ label: '运行时间', value: formatUptime(data.uptime || 0) },
|
||||
{ label: '版本', value: 'v1.0.0' },
|
||||
];
|
||||
items.forEach(item => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'info-item';
|
||||
div.innerHTML = `<div class="label">${item.label}</div><div class="value">${item.value}</div>`;
|
||||
grid.appendChild(div);
|
||||
});
|
||||
$('#server-info').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function updateServerStatus(online) {
|
||||
const dot = $('#server-status .status-dot');
|
||||
const text = $('#server-status .status-text');
|
||||
if (online) {
|
||||
dot.className = 'status-dot online';
|
||||
text.textContent = '已连接';
|
||||
} else {
|
||||
dot.className = 'status-dot offline';
|
||||
text.textContent = '未连接';
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Rooms Page =====
|
||||
$('#btn-refresh-rooms').addEventListener('click', loadRooms);
|
||||
|
||||
async function loadRooms() {
|
||||
if (!isConnected) {
|
||||
$('#rooms-list').innerHTML = '<div class="empty-state"><span class="empty-icon">🔗</span><p>请先连接服务器</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
$('#rooms-list').innerHTML = '<div class="empty-state"><span class="spinner"></span><p>加载中...</p></div>';
|
||||
|
||||
const result = await window.funmc.listRooms();
|
||||
if (!result.success) {
|
||||
$('#rooms-list').innerHTML = `<div class="empty-state"><span class="empty-icon">❌</span><p>${result.error}</p></div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const rooms = result.data.rooms || [];
|
||||
if (rooms.length === 0) {
|
||||
$('#rooms-list').innerHTML = '<div class="empty-state"><span class="empty-icon">🎮</span><p>暂无在线房间</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
$('#rooms-list').innerHTML = rooms.map(room => `
|
||||
<div class="room-card" data-id="${room.id}">
|
||||
<div class="room-left">
|
||||
<div class="room-icon">🎮</div>
|
||||
<div>
|
||||
<div class="room-name">${escapeHtml(room.name)} ${room.password ? '🔒' : ''}</div>
|
||||
<div class="room-meta">房间号: ${room.id} | 房主: ${escapeHtml(room.hostName)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="room-right">
|
||||
<span class="room-players">👥 ${room.currentPlayers}/${room.maxPlayers}</span>
|
||||
<span class="room-version">${room.gameEdition === 'java' ? 'Java' : '基岩'} ${room.gameVersion}</span>
|
||||
<button class="room-btn-join" onclick="quickJoin('${room.id}')">加入</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function quickJoin(roomId) {
|
||||
// Navigate to join page and fill in room ID
|
||||
$$('.nav-item').forEach(n => n.classList.remove('active'));
|
||||
document.querySelector('[data-page="join"]').classList.add('active');
|
||||
$$('.page').forEach(p => p.classList.remove('active'));
|
||||
$('#page-join').classList.add('active');
|
||||
$('#join-room-id').value = roomId;
|
||||
}
|
||||
|
||||
// ===== Host Page =====
|
||||
$('#btn-host').addEventListener('click', async () => {
|
||||
if (!isConnected) {
|
||||
showMsg('#host-msg', '请先连接服务器', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const name = $('#host-name').value.trim();
|
||||
const port = parseInt($('#host-port').value);
|
||||
const version = $('#host-version').value.trim();
|
||||
const edition = $('#host-edition').value;
|
||||
const maxPlayers = parseInt($('#host-maxplayers').value);
|
||||
const password = $('#host-password').value;
|
||||
|
||||
if (!name) {
|
||||
showMsg('#host-msg', '请输入房间名称', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = $('#btn-host');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner"></span> 创建中...';
|
||||
showMsg('#host-msg', '', '');
|
||||
|
||||
const result = await window.funmc.hostRoom({
|
||||
serverUrl: currentServerUrl,
|
||||
roomName: name,
|
||||
localMcPort: port,
|
||||
gameVersion: version,
|
||||
gameEdition: edition,
|
||||
maxPlayers: maxPlayers,
|
||||
password: password || undefined,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
showMsg('#host-msg', '', '');
|
||||
$('#host-room-id').textContent = result.data.room.id;
|
||||
$('#host-result').classList.remove('hidden');
|
||||
} else {
|
||||
showMsg('#host-msg', '创建失败: ' + result.error, 'error');
|
||||
}
|
||||
|
||||
btn.disabled = false;
|
||||
btn.textContent = '创建房间';
|
||||
});
|
||||
|
||||
$('#btn-copy-room').addEventListener('click', () => {
|
||||
const roomId = $('#host-room-id').textContent;
|
||||
navigator.clipboard.writeText(roomId).then(() => {
|
||||
$('#btn-copy-room').textContent = '已复制!';
|
||||
setTimeout(() => { $('#btn-copy-room').textContent = '复制房间号'; }, 2000);
|
||||
});
|
||||
});
|
||||
|
||||
// ===== Join Page =====
|
||||
$('#btn-join').addEventListener('click', async () => {
|
||||
const roomId = $('#join-room-id').value.trim();
|
||||
const host = $('#join-host').value.trim();
|
||||
const port = parseInt($('#join-port').value);
|
||||
const localPort = parseInt($('#join-local-port').value);
|
||||
|
||||
if (!roomId) {
|
||||
showMsg('#join-msg', '请输入房间号', 'error');
|
||||
return;
|
||||
}
|
||||
if (!host) {
|
||||
showMsg('#join-msg', '请输入中继服务器地址', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = $('#btn-join');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner"></span> 连接中...';
|
||||
showMsg('#join-msg', '', '');
|
||||
|
||||
const result = await window.funmc.joinRoom({
|
||||
serverHost: host,
|
||||
serverPort: port,
|
||||
roomId: roomId,
|
||||
localPort: localPort,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
showMsg('#join-msg', '', '');
|
||||
$('#join-active-port').textContent = String(localPort);
|
||||
$('#join-status').classList.remove('hidden');
|
||||
$('#btn-join').classList.add('hidden');
|
||||
$('#btn-leave').classList.remove('hidden');
|
||||
} else {
|
||||
showMsg('#join-msg', '加入失败: ' + result.error, 'error');
|
||||
}
|
||||
|
||||
btn.disabled = false;
|
||||
btn.textContent = '加入房间';
|
||||
});
|
||||
|
||||
$('#btn-leave').addEventListener('click', async () => {
|
||||
await window.funmc.disconnect();
|
||||
$('#join-status').classList.add('hidden');
|
||||
$('#btn-join').classList.remove('hidden');
|
||||
$('#btn-leave').classList.add('hidden');
|
||||
showMsg('#join-msg', '已断开连接', 'success');
|
||||
});
|
||||
|
||||
// ===== Relay Status Events =====
|
||||
window.funmc.onRelayStatus((data) => {
|
||||
if (data.status === 'disconnected') {
|
||||
$('#join-status').classList.add('hidden');
|
||||
$('#btn-join').classList.remove('hidden');
|
||||
$('#btn-leave').classList.add('hidden');
|
||||
showMsg('#join-msg', '连接已断开', 'error');
|
||||
} else if (data.status === 'error') {
|
||||
showMsg('#join-msg', '连接错误: ' + data.error, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// ===== Auto-refresh rooms when navigating =====
|
||||
document.querySelector('[data-page="rooms"]').addEventListener('click', () => {
|
||||
if (isConnected) loadRooms();
|
||||
});
|
||||
|
||||
// ===== Utilities =====
|
||||
function showMsg(selector, text, type) {
|
||||
const el = $(selector);
|
||||
el.textContent = text;
|
||||
el.className = 'msg' + (type ? ' ' + type : '');
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function formatUptime(seconds) {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
if (h > 0) return `${h}时${m}分${s}秒`;
|
||||
if (m > 0) return `${m}分${s}秒`;
|
||||
return `${s}秒`;
|
||||
}
|
||||
234
client/renderer/index.html
Normal file
234
client/renderer/index.html
Normal file
@@ -0,0 +1,234 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; script-src 'self'">
|
||||
<title>FunConnect</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Title Bar -->
|
||||
<div class="titlebar" id="titlebar">
|
||||
<div class="titlebar-drag">
|
||||
<span class="titlebar-icon">⛏</span>
|
||||
<span class="titlebar-title">FunConnect</span>
|
||||
</div>
|
||||
<div class="titlebar-controls">
|
||||
<button class="tb-btn" id="btn-min">─</button>
|
||||
<button class="tb-btn" id="btn-max">□</button>
|
||||
<button class="tb-btn tb-close" id="btn-close">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Container -->
|
||||
<div class="app">
|
||||
<!-- Sidebar -->
|
||||
<nav class="sidebar">
|
||||
<div class="sidebar-logo">
|
||||
<div class="logo-icon">F</div>
|
||||
<div class="logo-text">
|
||||
<span class="logo-name">FunConnect</span>
|
||||
<span class="logo-sub">MC联机客户端</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sidebar-status" id="server-status">
|
||||
<span class="status-dot offline"></span>
|
||||
<span class="status-text">未连接</span>
|
||||
</div>
|
||||
<ul class="sidebar-nav">
|
||||
<li class="nav-item active" data-page="connect">
|
||||
<span class="nav-icon">🔗</span> 连接服务器
|
||||
</li>
|
||||
<li class="nav-item" data-page="rooms">
|
||||
<span class="nav-icon">🎮</span> 房间列表
|
||||
</li>
|
||||
<li class="nav-item" data-page="host">
|
||||
<span class="nav-icon">🏠</span> 创建房间
|
||||
</li>
|
||||
<li class="nav-item" data-page="join">
|
||||
<span class="nav-icon">🚀</span> 加入房间
|
||||
</li>
|
||||
<li class="nav-item" data-page="about">
|
||||
<span class="nav-icon">ℹ️</span> 关于
|
||||
</li>
|
||||
</ul>
|
||||
<div class="sidebar-footer">v1.0.0</div>
|
||||
</nav>
|
||||
|
||||
<!-- Content -->
|
||||
<main class="content">
|
||||
<!-- Connect Page -->
|
||||
<div class="page active" id="page-connect">
|
||||
<h2 class="page-title">连接服务器</h2>
|
||||
<p class="page-desc">输入FunMC中继服务器地址来开始联机</p>
|
||||
<div class="card">
|
||||
<div class="form-group">
|
||||
<label>服务器地址</label>
|
||||
<input type="text" id="server-url" class="input" placeholder="http://your-server:3000" value="http://localhost:3000">
|
||||
</div>
|
||||
<button class="btn btn-primary" id="btn-connect">连接服务器</button>
|
||||
<div class="msg" id="connect-msg"></div>
|
||||
<div class="server-info hidden" id="server-info">
|
||||
<h3>服务器信息</h3>
|
||||
<div class="info-grid" id="server-info-grid"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rooms Page -->
|
||||
<div class="page" id="page-rooms">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="page-title">房间列表</h2>
|
||||
<p class="page-desc">当前在线的联机房间</p>
|
||||
</div>
|
||||
<button class="btn btn-secondary" id="btn-refresh-rooms">刷新</button>
|
||||
</div>
|
||||
<div id="rooms-list" class="rooms-grid">
|
||||
<div class="empty-state">
|
||||
<span class="empty-icon">🎮</span>
|
||||
<p>请先连接服务器</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Host Page -->
|
||||
<div class="page" id="page-host">
|
||||
<h2 class="page-title">创建房间</h2>
|
||||
<p class="page-desc">将你的本地Minecraft服务器共享给好友</p>
|
||||
<div class="card">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>房间名称</label>
|
||||
<input type="text" id="host-name" class="input" placeholder="我的联机房间">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>本地MC端口</label>
|
||||
<input type="number" id="host-port" class="input" value="25565">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>游戏版本</label>
|
||||
<input type="text" id="host-version" class="input" value="1.20.4">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>版本类型</label>
|
||||
<select id="host-edition" class="input">
|
||||
<option value="java">Java版</option>
|
||||
<option value="bedrock">基岩版</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>最大人数</label>
|
||||
<input type="number" id="host-maxplayers" class="input" value="10" min="2" max="100">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>房间密码(可选)</label>
|
||||
<input type="password" id="host-password" class="input" placeholder="留空则不设密码">
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-primary" id="btn-host">创建房间</button>
|
||||
<div class="msg" id="host-msg"></div>
|
||||
<div class="room-result hidden" id="host-result">
|
||||
<div class="result-title">房间创建成功!</div>
|
||||
<div class="result-room-id" id="host-room-id"></div>
|
||||
<p class="result-hint">将此房间号发给好友,他们可以通过「加入房间」连接</p>
|
||||
<button class="btn btn-secondary" id="btn-copy-room">复制房间号</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Join Page -->
|
||||
<div class="page" id="page-join">
|
||||
<h2 class="page-title">加入房间</h2>
|
||||
<p class="page-desc">输入房间号加入好友的联机房间</p>
|
||||
<div class="card">
|
||||
<div class="form-group">
|
||||
<label>房间号</label>
|
||||
<input type="text" id="join-room-id" class="input" placeholder="输入房间号">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>中继服务器地址</label>
|
||||
<input type="text" id="join-host" class="input" placeholder="relay.funmc.cn" value="localhost">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>中继端口</label>
|
||||
<input type="number" id="join-port" class="input" value="25565">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>本地监听端口</label>
|
||||
<input type="number" id="join-local-port" class="input" value="25566">
|
||||
<span class="form-hint">在Minecraft中添加服务器地址 127.0.0.1:25566 即可连接</span>
|
||||
</div>
|
||||
<button class="btn btn-primary" id="btn-join">加入房间</button>
|
||||
<button class="btn btn-danger hidden" id="btn-leave">断开连接</button>
|
||||
<div class="msg" id="join-msg"></div>
|
||||
<div class="join-status hidden" id="join-status">
|
||||
<div class="status-card connected">
|
||||
<span class="status-emoji">✅</span>
|
||||
<div>
|
||||
<div class="status-title">已连接</div>
|
||||
<div class="status-detail">在Minecraft中添加服务器: <strong>127.0.0.1:<span id="join-active-port">25566</span></strong></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- About Page -->
|
||||
<div class="page" id="page-about">
|
||||
<h2 class="page-title">关于 FunConnect</h2>
|
||||
<div class="card">
|
||||
<div class="about-header">
|
||||
<div class="about-logo">F</div>
|
||||
<div>
|
||||
<h3>FunConnect v1.0.0</h3>
|
||||
<p>Minecraft 联机客户端</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="about-section">
|
||||
<h4>使用说明</h4>
|
||||
<div class="steps">
|
||||
<div class="step">
|
||||
<span class="step-num">1</span>
|
||||
<div>
|
||||
<strong>连接服务器</strong>
|
||||
<p>输入FunMC中继服务器地址并连接</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step">
|
||||
<span class="step-num">2</span>
|
||||
<div>
|
||||
<strong>创建或加入房间</strong>
|
||||
<p>房主创建房间后分享房间号,玩家输入房间号加入</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step">
|
||||
<span class="step-num">3</span>
|
||||
<div>
|
||||
<strong>开始游戏</strong>
|
||||
<p>在Minecraft中添加服务器地址 127.0.0.1:端口 即可联机</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="about-section">
|
||||
<h4>技术信息</h4>
|
||||
<p>基于 Electron + Node.js TCP 中继技术</p>
|
||||
<p>支持 Java版 和 基岩版 Minecraft</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
436
client/renderer/style.css
Normal file
436
client/renderer/style.css
Normal file
@@ -0,0 +1,436 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg-dark: #1a1a2e;
|
||||
--bg-darker: #16213e;
|
||||
--bg-accent: #0f3460;
|
||||
--green: #4CAF50;
|
||||
--green-hover: #45a049;
|
||||
--red: #e94560;
|
||||
--gold: #FFD700;
|
||||
--blue: #4fc3f7;
|
||||
--purple: #b388ff;
|
||||
--text: #e0e0e0;
|
||||
--text-dim: #8a8a9a;
|
||||
--border: rgba(15, 52, 96, 0.4);
|
||||
--radius: 10px;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', 'Microsoft YaHei', sans-serif;
|
||||
background: var(--bg-dark);
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
height: 100vh;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Titlebar */
|
||||
.titlebar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 36px;
|
||||
background: #111128;
|
||||
border-bottom: 1px solid var(--border);
|
||||
-webkit-app-region: drag;
|
||||
padding: 0 8px;
|
||||
}
|
||||
.titlebar-drag {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.titlebar-icon { font-size: 14px; }
|
||||
.titlebar-title { font-size: 12px; color: var(--text-dim); }
|
||||
.titlebar-controls {
|
||||
display: flex;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
.tb-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-dim);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.tb-btn:hover { background: rgba(255,255,255,0.1); }
|
||||
.tb-close:hover { background: var(--red); color: #fff; }
|
||||
|
||||
/* App Layout */
|
||||
.app {
|
||||
display: flex;
|
||||
height: calc(100vh - 36px);
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
.sidebar {
|
||||
width: 220px;
|
||||
background: var(--bg-darker);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sidebar-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 20px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.logo-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, var(--green), #2e7d32);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: 'Press Start 2P', monospace;
|
||||
font-size: 16px;
|
||||
color: #fff;
|
||||
}
|
||||
.logo-name {
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
color: var(--green);
|
||||
display: block;
|
||||
}
|
||||
.logo-sub {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.sidebar-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.status-dot.online { background: var(--green); box-shadow: 0 0 6px var(--green); }
|
||||
.status-dot.offline { background: var(--red); }
|
||||
.sidebar-nav {
|
||||
list-style: none;
|
||||
padding: 12px 8px;
|
||||
flex: 1;
|
||||
}
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.nav-item:hover {
|
||||
background: rgba(15, 52, 96, 0.4);
|
||||
color: var(--text);
|
||||
}
|
||||
.nav-item.active {
|
||||
background: rgba(76, 175, 80, 0.15);
|
||||
color: var(--green);
|
||||
border: 1px solid rgba(76, 175, 80, 0.3);
|
||||
}
|
||||
.nav-icon { font-size: 16px; }
|
||||
.sidebar-footer {
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Content */
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 28px 32px;
|
||||
}
|
||||
.content::-webkit-scrollbar { width: 6px; }
|
||||
.content::-webkit-scrollbar-track { background: transparent; }
|
||||
.content::-webkit-scrollbar-thumb { background: var(--bg-accent); border-radius: 3px; }
|
||||
|
||||
/* Pages */
|
||||
.page { display: none; }
|
||||
.page.active { display: block; animation: fadeIn 0.2s ease; }
|
||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
|
||||
|
||||
.page-title { font-size: 22px; font-weight: 700; margin-bottom: 4px; }
|
||||
.page-desc { font-size: 13px; color: var(--text-dim); margin-bottom: 24px; }
|
||||
.page-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 24px; }
|
||||
|
||||
/* Card */
|
||||
.card {
|
||||
background: var(--bg-darker);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
/* Form */
|
||||
.form-group { margin-bottom: 16px; }
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.form-row { display: flex; gap: 16px; }
|
||||
.form-row .form-group { flex: 1; }
|
||||
.form-hint { font-size: 11px; color: var(--text-dim); margin-top: 4px; display: block; }
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
background: var(--bg-dark);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.input:focus { border-color: var(--green); }
|
||||
.input::placeholder { color: #555; }
|
||||
select.input { cursor: pointer; }
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.btn:active { transform: scale(0.97); }
|
||||
.btn-primary { background: var(--green); color: #fff; }
|
||||
.btn-primary:hover { background: var(--green-hover); }
|
||||
.btn-secondary { background: var(--bg-accent); color: var(--text); }
|
||||
.btn-secondary:hover { background: #1a4a7a; }
|
||||
.btn-danger { background: var(--red); color: #fff; }
|
||||
.btn-danger:hover { background: #d63050; }
|
||||
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
/* Messages */
|
||||
.msg {
|
||||
margin-top: 12px;
|
||||
font-size: 13px;
|
||||
min-height: 20px;
|
||||
}
|
||||
.msg.error { color: var(--red); }
|
||||
.msg.success { color: var(--green); }
|
||||
|
||||
/* Server Info */
|
||||
.server-info { margin-top: 20px; }
|
||||
.server-info h3 {
|
||||
font-size: 14px;
|
||||
margin-bottom: 12px;
|
||||
color: var(--green);
|
||||
}
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
.info-item {
|
||||
background: var(--bg-dark);
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
.info-item .label {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.info-item .value {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Rooms Grid */
|
||||
.rooms-grid { display: flex; flex-direction: column; gap: 12px; }
|
||||
.room-card {
|
||||
background: var(--bg-darker);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.room-card:hover { border-color: rgba(76, 175, 80, 0.4); }
|
||||
.room-left { display: flex; align-items: center; gap: 14px; }
|
||||
.room-icon {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 10px;
|
||||
background: rgba(76, 175, 80, 0.15);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
}
|
||||
.room-name { font-weight: 600; font-size: 14px; }
|
||||
.room-meta { font-size: 11px; color: var(--text-dim); margin-top: 3px; }
|
||||
.room-right { display: flex; align-items: center; gap: 10px; }
|
||||
.room-players {
|
||||
font-size: 12px;
|
||||
color: var(--blue);
|
||||
background: rgba(79, 195, 247, 0.1);
|
||||
padding: 4px 10px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
.room-version {
|
||||
font-size: 12px;
|
||||
color: var(--purple);
|
||||
background: rgba(179, 136, 255, 0.1);
|
||||
padding: 4px 10px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
.room-btn-join {
|
||||
padding: 6px 14px;
|
||||
background: var(--green);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.room-btn-join:hover { background: var(--green-hover); }
|
||||
|
||||
/* Empty State */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 48px 0;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.empty-icon { font-size: 48px; display: block; margin-bottom: 12px; }
|
||||
|
||||
/* Room Result */
|
||||
.room-result {
|
||||
margin-top: 20px;
|
||||
background: var(--bg-dark);
|
||||
border-radius: var(--radius);
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
.result-title { font-size: 18px; font-weight: 700; color: var(--green); margin-bottom: 12px; }
|
||||
.result-room-id {
|
||||
font-family: 'Press Start 2P', monospace;
|
||||
font-size: 22px;
|
||||
color: var(--gold);
|
||||
letter-spacing: 2px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
.result-hint { font-size: 12px; color: var(--text-dim); margin-bottom: 16px; }
|
||||
|
||||
/* Join Status */
|
||||
.join-status { margin-top: 16px; }
|
||||
.status-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 16px;
|
||||
border-radius: var(--radius);
|
||||
background: rgba(76, 175, 80, 0.1);
|
||||
border: 1px solid rgba(76, 175, 80, 0.3);
|
||||
}
|
||||
.status-emoji { font-size: 28px; }
|
||||
.status-title { font-weight: 600; font-size: 14px; color: var(--green); }
|
||||
.status-detail { font-size: 12px; color: var(--text-dim); margin-top: 4px; }
|
||||
.status-detail strong { color: var(--gold); }
|
||||
|
||||
/* About */
|
||||
.about-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.about-logo {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(135deg, var(--green), #2e7d32);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: 'Press Start 2P', monospace;
|
||||
font-size: 22px;
|
||||
color: #fff;
|
||||
}
|
||||
.about-header h3 { font-size: 18px; }
|
||||
.about-header p { font-size: 13px; color: var(--text-dim); margin-top: 2px; }
|
||||
.about-section { margin-top: 20px; }
|
||||
.about-section h4 { font-size: 14px; margin-bottom: 12px; color: var(--green); }
|
||||
.about-section p { font-size: 13px; color: var(--text-dim); margin-bottom: 6px; }
|
||||
|
||||
.steps { display: flex; flex-direction: column; gap: 12px; }
|
||||
.step {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
background: var(--bg-dark);
|
||||
border-radius: 8px;
|
||||
padding: 14px;
|
||||
}
|
||||
.step-num {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: rgba(76, 175, 80, 0.2);
|
||||
color: var(--green);
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.step strong { font-size: 13px; display: block; margin-bottom: 3px; }
|
||||
.step p { font-size: 12px; color: var(--text-dim); }
|
||||
|
||||
/* Utility */
|
||||
.hidden { display: none !important; }
|
||||
|
||||
/* Loading spinner */
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid rgba(255,255,255,0.3);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
50
client/src/main/api-client.ts
Normal file
50
client/src/main/api-client.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
|
||||
export class ApiClient {
|
||||
private http: AxiosInstance;
|
||||
|
||||
constructor(baseUrl: string) {
|
||||
this.http = axios.create({
|
||||
baseURL: baseUrl.replace(/\/$/, '') + '/api',
|
||||
timeout: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
async getHealth() {
|
||||
const res = await this.http.get('/health');
|
||||
return res.data;
|
||||
}
|
||||
|
||||
async getStats() {
|
||||
const res = await this.http.get('/stats');
|
||||
return res.data;
|
||||
}
|
||||
|
||||
async getRooms() {
|
||||
const res = await this.http.get('/rooms');
|
||||
return res.data;
|
||||
}
|
||||
|
||||
async createRoom(data: {
|
||||
name: string;
|
||||
hostName: string;
|
||||
hostPort: number;
|
||||
gameVersion: string;
|
||||
gameEdition: string;
|
||||
maxPlayers: number;
|
||||
password?: string;
|
||||
}) {
|
||||
const res = await this.http.post('/rooms', data);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
async deleteRoom(roomId: string) {
|
||||
const res = await this.http.delete(`/rooms/${roomId}`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
async getRoom(roomId: string) {
|
||||
const res = await this.http.get(`/rooms/${roomId}`);
|
||||
return res.data;
|
||||
}
|
||||
}
|
||||
173
client/src/main/index.ts
Normal file
173
client/src/main/index.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { app, BrowserWindow, ipcMain, dialog } from 'electron';
|
||||
import * as path from 'path';
|
||||
import { RelayClient } from './relay-client';
|
||||
import { LocalProxy } from './local-proxy';
|
||||
import { ApiClient } from './api-client';
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let relayClient: RelayClient | null = null;
|
||||
let localProxy: LocalProxy | null = null;
|
||||
let apiClient: ApiClient | null = null;
|
||||
|
||||
function createWindow() {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 900,
|
||||
height: 650,
|
||||
minWidth: 800,
|
||||
minHeight: 600,
|
||||
title: 'FunConnect - Minecraft 联机客户端',
|
||||
frame: false,
|
||||
titleBarStyle: 'hidden',
|
||||
backgroundColor: '#1a1a2e',
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
});
|
||||
|
||||
mainWindow.loadFile(path.join(__dirname, '../../renderer/index.html'));
|
||||
|
||||
mainWindow.on('closed', () => {
|
||||
mainWindow = null;
|
||||
cleanup();
|
||||
});
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
if (localProxy) {
|
||||
localProxy.stop();
|
||||
localProxy = null;
|
||||
}
|
||||
if (relayClient) {
|
||||
relayClient.disconnect();
|
||||
relayClient = null;
|
||||
}
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
createWindow();
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
||||
});
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
cleanup();
|
||||
if (process.platform !== 'darwin') app.quit();
|
||||
});
|
||||
|
||||
// ===== IPC Handlers =====
|
||||
|
||||
// Window controls
|
||||
ipcMain.handle('window:minimize', () => mainWindow?.minimize());
|
||||
ipcMain.handle('window:maximize', () => {
|
||||
if (mainWindow?.isMaximized()) mainWindow.unmaximize();
|
||||
else mainWindow?.maximize();
|
||||
});
|
||||
ipcMain.handle('window:close', () => mainWindow?.close());
|
||||
|
||||
// Server connection
|
||||
ipcMain.handle('server:connect', async (_event, serverUrl: string) => {
|
||||
try {
|
||||
apiClient = new ApiClient(serverUrl);
|
||||
const health = await apiClient.getHealth();
|
||||
return { success: true, data: health };
|
||||
} catch (err: any) {
|
||||
return { success: false, error: err.message };
|
||||
}
|
||||
});
|
||||
|
||||
// Room operations
|
||||
ipcMain.handle('rooms:list', async () => {
|
||||
if (!apiClient) return { success: false, error: '未连接服务器' };
|
||||
try {
|
||||
const data = await apiClient.getRooms();
|
||||
return { success: true, data };
|
||||
} catch (err: any) {
|
||||
return { success: false, error: err.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('rooms:create', async (_event, roomData: any) => {
|
||||
if (!apiClient) return { success: false, error: '未连接服务器' };
|
||||
try {
|
||||
const data = await apiClient.createRoom(roomData);
|
||||
return { success: true, data };
|
||||
} catch (err: any) {
|
||||
return { success: false, error: err.response?.data?.error || err.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('rooms:delete', async (_event, roomId: string) => {
|
||||
if (!apiClient) return { success: false, error: '未连接服务器' };
|
||||
try {
|
||||
await apiClient.deleteRoom(roomId);
|
||||
return { success: true };
|
||||
} catch (err: any) {
|
||||
return { success: false, error: err.message };
|
||||
}
|
||||
});
|
||||
|
||||
// Join room - start local proxy and relay connection
|
||||
ipcMain.handle('rooms:join', async (_event, opts: { serverHost: string; serverPort: number; roomId: string; localPort: number }) => {
|
||||
try {
|
||||
cleanup();
|
||||
|
||||
relayClient = new RelayClient(opts.serverHost, opts.serverPort, opts.roomId);
|
||||
localProxy = new LocalProxy(opts.localPort, relayClient);
|
||||
|
||||
await localProxy.start();
|
||||
|
||||
relayClient.on('connected', () => {
|
||||
mainWindow?.webContents.send('relay:status', { status: 'connected' });
|
||||
});
|
||||
relayClient.on('disconnected', () => {
|
||||
mainWindow?.webContents.send('relay:status', { status: 'disconnected' });
|
||||
});
|
||||
relayClient.on('error', (err: string) => {
|
||||
mainWindow?.webContents.send('relay:status', { status: 'error', error: err });
|
||||
});
|
||||
|
||||
return { success: true, localPort: opts.localPort };
|
||||
} catch (err: any) {
|
||||
return { success: false, error: err.message };
|
||||
}
|
||||
});
|
||||
|
||||
// Host room - start relay and local proxy from local MC server
|
||||
ipcMain.handle('rooms:host', async (_event, opts: { serverUrl: string; roomName: string; localMcPort: number; gameVersion: string; gameEdition: string; maxPlayers: number; password?: string }) => {
|
||||
if (!apiClient) return { success: false, error: '未连接服务器' };
|
||||
try {
|
||||
const result = await apiClient.createRoom({
|
||||
name: opts.roomName,
|
||||
hostName: 'FunConnect',
|
||||
hostPort: opts.localMcPort,
|
||||
gameVersion: opts.gameVersion,
|
||||
gameEdition: opts.gameEdition,
|
||||
maxPlayers: opts.maxPlayers,
|
||||
password: opts.password,
|
||||
});
|
||||
return { success: true, data: result };
|
||||
} catch (err: any) {
|
||||
return { success: false, error: err.response?.data?.error || err.message };
|
||||
}
|
||||
});
|
||||
|
||||
// Disconnect
|
||||
ipcMain.handle('relay:disconnect', () => {
|
||||
cleanup();
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// Stats
|
||||
ipcMain.handle('server:stats', async () => {
|
||||
if (!apiClient) return { success: false, error: '未连接服务器' };
|
||||
try {
|
||||
const data = await apiClient.getStats();
|
||||
return { success: true, data };
|
||||
} catch (err: any) {
|
||||
return { success: false, error: err.message };
|
||||
}
|
||||
});
|
||||
77
client/src/main/local-proxy.ts
Normal file
77
client/src/main/local-proxy.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import * as net from 'net';
|
||||
import { RelayClient } from './relay-client';
|
||||
|
||||
export class LocalProxy {
|
||||
private server: net.Server | null = null;
|
||||
private connections: Set<net.Socket> = new Set();
|
||||
|
||||
constructor(
|
||||
private port: number,
|
||||
private relayClient: RelayClient
|
||||
) {}
|
||||
|
||||
start(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.server = net.createServer((clientSocket) => {
|
||||
this.connections.add(clientSocket);
|
||||
|
||||
// Connect to relay for this MC client
|
||||
const relaySocket = this.relayClient.getSocket();
|
||||
if (!relaySocket || !this.relayClient.isConnected()) {
|
||||
clientSocket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
// Forward MC client data to relay
|
||||
clientSocket.on('data', (data) => {
|
||||
if (this.relayClient.isConnected()) {
|
||||
this.relayClient.write(data);
|
||||
}
|
||||
});
|
||||
|
||||
// Forward relay data back to MC client
|
||||
this.relayClient.onData((data) => {
|
||||
if (!clientSocket.destroyed) {
|
||||
clientSocket.write(data);
|
||||
}
|
||||
});
|
||||
|
||||
clientSocket.on('close', () => {
|
||||
this.connections.delete(clientSocket);
|
||||
});
|
||||
|
||||
clientSocket.on('error', () => {
|
||||
this.connections.delete(clientSocket);
|
||||
});
|
||||
});
|
||||
|
||||
this.server.on('error', (err) => {
|
||||
reject(err);
|
||||
});
|
||||
|
||||
this.server.listen(this.port, '127.0.0.1', () => {
|
||||
console.log(`[LocalProxy] Listening on 127.0.0.1:${this.port}`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
for (const conn of this.connections) {
|
||||
conn.destroy();
|
||||
}
|
||||
this.connections.clear();
|
||||
if (this.server) {
|
||||
this.server.close();
|
||||
this.server = null;
|
||||
}
|
||||
}
|
||||
|
||||
getPort(): number {
|
||||
return this.port;
|
||||
}
|
||||
|
||||
getConnectionCount(): number {
|
||||
return this.connections.size;
|
||||
}
|
||||
}
|
||||
25
client/src/main/preload.ts
Normal file
25
client/src/main/preload.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron';
|
||||
|
||||
contextBridge.exposeInMainWorld('funmc', {
|
||||
// Window controls
|
||||
minimize: () => ipcRenderer.invoke('window:minimize'),
|
||||
maximize: () => ipcRenderer.invoke('window:maximize'),
|
||||
close: () => ipcRenderer.invoke('window:close'),
|
||||
|
||||
// Server
|
||||
connectServer: (url: string) => ipcRenderer.invoke('server:connect', url),
|
||||
getStats: () => ipcRenderer.invoke('server:stats'),
|
||||
|
||||
// Rooms
|
||||
listRooms: () => ipcRenderer.invoke('rooms:list'),
|
||||
createRoom: (data: any) => ipcRenderer.invoke('rooms:create', data),
|
||||
deleteRoom: (id: string) => ipcRenderer.invoke('rooms:delete', id),
|
||||
joinRoom: (opts: any) => ipcRenderer.invoke('rooms:join', opts),
|
||||
hostRoom: (opts: any) => ipcRenderer.invoke('rooms:host', opts),
|
||||
disconnect: () => ipcRenderer.invoke('relay:disconnect'),
|
||||
|
||||
// Events from main
|
||||
onRelayStatus: (callback: (data: any) => void) => {
|
||||
ipcRenderer.on('relay:status', (_event, data) => callback(data));
|
||||
},
|
||||
});
|
||||
76
client/src/main/relay-client.ts
Normal file
76
client/src/main/relay-client.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import * as net from 'net';
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
export class RelayClient extends EventEmitter {
|
||||
private socket: net.Socket | null = null;
|
||||
private connected = false;
|
||||
private reconnectTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(
|
||||
private host: string,
|
||||
private port: number,
|
||||
private roomId: string
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
connect(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.socket = new net.Socket();
|
||||
|
||||
this.socket.connect(this.port, this.host, () => {
|
||||
this.connected = true;
|
||||
// Send room ID as initial handshake
|
||||
const header = Buffer.alloc(2 + this.roomId.length);
|
||||
header.writeUInt16BE(this.roomId.length, 0);
|
||||
header.write(this.roomId, 2);
|
||||
this.socket!.write(header);
|
||||
this.emit('connected');
|
||||
resolve();
|
||||
});
|
||||
|
||||
this.socket.on('error', (err) => {
|
||||
this.emit('error', err.message);
|
||||
if (!this.connected) reject(err);
|
||||
});
|
||||
|
||||
this.socket.on('close', () => {
|
||||
this.connected = false;
|
||||
this.emit('disconnected');
|
||||
});
|
||||
|
||||
this.socket.setTimeout(10000, () => {
|
||||
this.socket?.destroy(new Error('Connection timeout'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
write(data: Buffer): boolean {
|
||||
if (!this.socket || !this.connected) return false;
|
||||
return this.socket.write(data);
|
||||
}
|
||||
|
||||
onData(callback: (data: Buffer) => void): void {
|
||||
this.socket?.on('data', callback);
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
if (this.socket) {
|
||||
this.socket.destroy();
|
||||
this.socket = null;
|
||||
}
|
||||
this.connected = false;
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.connected;
|
||||
}
|
||||
|
||||
getSocket(): net.Socket | null {
|
||||
return this.socket;
|
||||
}
|
||||
}
|
||||
19
client/tsconfig.json
Normal file
19
client/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2020"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": false,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "renderer"]
|
||||
}
|
||||
Reference in New Issue
Block a user