feat: FunConnect v1.0.0 - Minecraft联机平台完整版

- server: Node.js TCP中继服务器,支持多节点集群
- web: React管理面板(仪表盘、房间管理、节点管理)
- client: Electron桌面客户端(连接、创建/加入房间、本地代理)
- deploy: Ubuntu一键部署脚本
This commit is contained in:
FunMC
2026-02-22 23:33:00 +08:00
commit b17679cec6
44 changed files with 13783 additions and 0 deletions

View 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
View 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 };
}
});

View 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;
}
}

View 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));
},
});

View 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;
}
}