feat: FunConnect v1.0.0 - Minecraft联机平台完整版
- server: Node.js TCP中继服务器,支持多节点集群 - web: React管理面板(仪表盘、房间管理、节点管理) - client: Electron桌面客户端(连接、创建/加入房间、本地代理) - deploy: Ubuntu一键部署脚本
This commit is contained in:
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user