吾爱破解 - 52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 485|回复: 4
收起左侧

[会员申请] 申请会员ID:lllomh【申请通过】

[复制链接]
吾爱游客  发表于 2026-3-11 11:00

申请会员ID:lllomh

1、申 请 I D: 芈渡
2、个人邮箱: lllomh@qq.com
3、原创技术文章: https://www.lllomh.com/article/details?id=1773196745


【原创】从零开始用 Electron + React 打造 N2N 内网穿透联机工具——完整踩坑记录

一、项目背景与动机

相信很多人都有过这样的经历:想和朋友联机玩游戏,却因为各自处于不同的网络环境下(家庭宽带、校园网、4G热点)而无法直接组建局域网。市面上虽然有 Hamachi、ZeroTier 这类商业方案,但要么需要注册账号,要么有人数限制,要么速度堪忧。

N2N 是一个开源的 P2P VPN 工具,原理是通过一台有公网 IP 的 supernode 服务器做中转协调,让各个 edge 节点之间建立虚拟局域网,实际数据传输走点对点,延迟低、速度快。但它的问题在于:纯命令行操作,参数繁琐,普通玩家根本用不来

于是我决定用 Electron + React + Node.js + MySQL 给它做一个图形化管理工具,实现:

  • 可视化配置和管理多个联机房间
  • 一键连接/断开,自动拼装 N2N 参数
  • 实时显示房间内在线成员列表
  • 本地持久化保存房间配置,下次直接用

这篇文章完整记录了开发过程中遇到的技术难点和解决思路,特别是 Node.js 调用并管理 N2N 子进程 这块,踩了相当多的坑。


二、技术选型与整体架构

2.1 为什么选 Electron
  • 需要调用系统底层(子进程、网卡驱动),纯 Web 方案做不到
  • 团队熟悉 React,用 Electron 可以复用前端技术栈
  • 跨平台打包方便,Windows/Mac 都能出包
2.2 整体架构
┌──────────────────────────────────────────────┐
│              React 渲染进程(前端)             │
│                                              │
│  ┌──────────┐  ┌──────────┐  ┌───────────┐  │
│  │ 房间列表  │  │ 成员列表  │  │  配置面板  │  │
│  └──────────┘  └──────────┘  └───────────┘  │
└───────────────────┬──────────────────────────┘
                    │  IPC (ipcRenderer / ipcMain)
┌───────────────────▼──────────────────────────┐
│              Electron 主进程                  │
│                                              │
│  ┌──────────────────────────────────────┐    │
│  │           IPC Handler 层              │    │
│  │  connect-room / disconnect-room 等    │    │
│  └──────────────────┬───────────────────┘    │
│                     │                        │
│  ┌──────────────────▼───────────────────┐    │
│  │         ProcessManager               │    │
│  │   进程池 / 生命周期管理 / 输出解析     │    │
│  └──────────────────┬───────────────────┘    │
│                     │  child_process.spawn   │
└─────────────────────┼────────────────────────┘
                      │
         ┌────────────▼────────────┐
         │      N2N 子进程          │
         │  edge.exe / supernode   │
         └─────────────────────────┘
                      │
         ┌────────────▼────────────┐
         │    MySQL 本地数据库      │
         │  房间配置 / 历史记录      │
         └─────────────────────────┘

主进程负责所有系统调用和进程管理,渲染进程只负责展示和交互,两者通过 IPC 通信,严格遵循 Electron 的安全模型。


三、项目初始化与工程结构

3.1 初始化项目
npx create-react-app n2n-tool
cd n2n-tool
npm install electron electron-builder concurrently wait-on --save-dev
npm install mysql2 electron-store
3.2 目录结构
n2n-tool/
├── public/
│   └── electron.js          # Electron 主进程入口
├── src/
│   ├── components/
│   │   ├── RoomList.jsx      # 房间列表组件
│   │   ├── MemberList.jsx    # 在线成员组件
│   │   └── RoomConfig.jsx    # 房间配置表单
│   ├── hooks/
│   │   └── useRoom.js        # 房间状态 Hook
│   ├── ipc/
│   │   └── renderer.js       # 渲染进程 IPC 封装
│   └── App.jsx
├── preload.js                # 预加载脚本
├── ipc/
│   ├── roomHandlers.js       # IPC 处理器
│   └── processManager.js    # N2N 进程管理核心
├── db/
│   └── index.js              # 数据库操作封装
└── n2n-bin/                  # N2N 可执行文件
    ├── edge.exe
    └── supernode.exe
3.3 package.json 关键配置
{
  "main": "public/electron.js",
  "scripts": {
    "dev": "concurrently \"npm start\" \"wait-on http://localhost:3000 && electron .\"",
    "build": "react-scripts build && electron-builder",
    "electron": "electron ."
  },
  "build": {
    "appId": "com.n2ntool.app",
    "productName": "N2N好朋友联机工具",
    "win": {
      "requestedExecutionLevel": "requireAdministrator",
      "target": "nsis"
    },
    "extraResources": [
      { "from": "n2n-bin", "to": "n2n-bin" }
    ],
    "files": [
      "build/**/*",
      "public/electron.js",
      "preload.js",
      "ipc/**/*",
      "db/**/*"
    ]
  }
}

注意 requestedExecutionLevel: requireAdministrator,N2N 操作 TAP 网卡需要管理员权限,这个不设置的话在 Windows 上会静默失败,非常难排查。


四、数据库设计与封装

4.1 表结构设计
-- 房间配置表
CREATE TABLE rooms (
  id           INT AUTO_INCREMENT PRIMARY KEY,
  name         VARCHAR(64)  NOT NULL UNIQUE  COMMENT '房间名,即N2N community字段',
  password     VARCHAR(128) NOT NULL         COMMENT '房间密码,N2N加密密钥',
  virtual_ip   VARCHAR(15)  NOT NULL         COMMENT '本机在虚拟网络中的IP',
  supernode    VARCHAR(128) NOT NULL         COMMENT 'supernode服务器地址',
  port         INT          DEFAULT 7654     COMMENT 'supernode端口',
  n2n_path     VARCHAR(256) NOT NULL         COMMENT 'N2N可执行文件所在目录',
  last_used_at TIMESTAMP    NULL             COMMENT '最后连接时间',
  created_at   TIMESTAMP    DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 连接历史表
CREATE TABLE connect_history (
  id         INT AUTO_INCREMENT PRIMARY KEY,
  room_name  VARCHAR(64) NOT NULL,
  connected_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  duration_sec INT DEFAULT 0  COMMENT '本次连接持续时间(秒)',
  INDEX idx_room (room_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
4.2 数据库操作封装(db/index.js)
const mysql = require('mysql2/promise')

let pool = null

function getPool() {
  if (!pool) {
    pool = mysql.createPool({
      host: '127.0.0.1',
      user: 'root',
      password: '',
      database: 'n2n_tool',
      waitForConnections: true,
      connectionLimit: 5,
      queueLimit: 0
    })
  }
  return pool
}

// 初始化建表(应用启动时调用)
async function initDB() {
  const conn = await getPool().getConnection()
  try {
    await conn.query(`CREATE DATABASE IF NOT EXISTS n2n_tool`)
    await conn.query(`USE n2n_tool`)
    await conn.query(`
      CREATE TABLE IF NOT EXISTS rooms (
        id           INT AUTO_INCREMENT PRIMARY KEY,
        name         VARCHAR(64)  NOT NULL UNIQUE,
        password     VARCHAR(128) NOT NULL,
        virtual_ip   VARCHAR(15)  NOT NULL,
        supernode    VARCHAR(128) NOT NULL,
        port         INT          DEFAULT 7654,
        n2n_path     VARCHAR(256) NOT NULL,
        last_used_at TIMESTAMP    NULL,
        created_at   TIMESTAMP    DEFAULT CURRENT_TIMESTAMP
      ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
    `)
    // ... 其他表
    console.log('[DB] 初始化完成')
  } finally {
    conn.release()
  }
}

async function getRooms() {
  const [rows] = await getPool().query(
    'SELECT * FROM rooms ORDER BY last_used_at DESC, created_at DESC'
  )
  return rows
}

async function saveRoom(config) {
  await getPool().query(
    `INSERT INTO rooms (name, password, virtual_ip, supernode, port, n2n_path)
     VALUES (?, ?, ?, ?, ?, ?)
     ON DUPLICATE KEY UPDATE
       password    = VALUES(password),
       virtual_ip  = VALUES(virtual_ip),
       supernode   = VALUES(supernode),
       port        = VALUES(port),
       n2n_path    = VALUES(n2n_path)`,
    [config.name, config.password, config.virtualIp,
     config.supernode, config.port, config.n2nPath]
  )
}

async function updateLastUsed(roomName) {
  await getPool().query(
    'UPDATE rooms SET last_used_at = NOW() WHERE name = ?',
    [roomName]
  )
}

async function deleteRoom(roomName) {
  await getPool().query('DELETE FROM rooms WHERE name = ?', [roomName])
}

module.exports = { initDB, getRooms, saveRoom, updateLastUsed, deleteRoom }

五、核心难点:N2N 进程管理(processManager.js)

这是整个项目技术含量最高、踩坑最多的部分,单独拎出来详细说。

5.1 为什么不能用 exec
// 错误示范,千万别这么写
const { exec } = require('child_process')
exec('edge.exe -c room1 -k pass -a 10.0.0.1 -l 1.2.3.4:7654', (err, stdout) => {
  // edge 是持续运行的进程,这个回调永远不会触发
  // 同时 stdout 也只有进程退出才能拿到,完全没用
})

exec 的本质是等进程退出后一次性返回输出,而 N2N 的 edge 是一个持续运行的守护进程,所以这条路完全行不通。

5.2 spawn 基础用法
const { spawn } = require('child_process')
const path = require('path')

function buildEdgeArgs(config) {
  return [
    '-c', config.name,                           // community(房间名)
    '-k', config.password,                       // 加密密钥
    '-a', config.virtualIp,                      // 本机虚拟IP
    '-l', `${config.supernode}:${config.port}`,  // supernode地址
    '-f',                                        // 前台运行(不 daemonize)
    '-v'                                         // 详细输出,方便解析
  ]
}

function startEdge(config, n2nDir) {
  const edgeBin = path.join(n2nDir, 'edge.exe')
  const args = buildEdgeArgs(config)

  const proc = spawn(edgeBin, args, {
    cwd: n2nDir,
    windowsHide: true,   // 隐藏 cmd 黑框,用户体验关键
    stdio: ['ignore', 'pipe', 'pipe']  // stdin忽略,stdout/stderr都接管
  })

  return proc
}
5.3 进程池设计——防僵尸进程

最初版本没有进程池,直接把进程对象存在一个普通变量里,结果发现:

  • 切换房间时旧进程没被 kill,两个 edge 同时跑,网络混乱
  • 应用崩溃时子进程变成僵尸,下次启动报端口占用
  • 无法从其他地方(如 IPC handler)拿到进程引用

改成 Map 实现进程池后解决了这些问题:

// ipc/processManager.js

const { spawn } = require('child_process')
const path = require('path')
const { app } = require('electron')

// 进程池:roomName -> { process, startTime, connected }
const pool = new Map()

// 获取 N2N 二进制文件目录(开发/生产环境路径不同)
function getN2nDir() {
  if (app.isPackaged) {
    return path.join(process.resourcesPath, 'n2n-bin')
  }
  return path.join(__dirname, '..', 'n2n-bin')
}

function isRunning(roomName) {
  return pool.has(roomName) && pool.get(roomName).process !== null
}

function killRoom(roomName) {
  if (!pool.has(roomName)) return
  const entry = pool.get(roomName)
  if (entry.process) {
    entry.process.kill('SIGTERM')
    // Windows 上 SIGTERM 可能无效,再补一刀
    setTimeout(() => {
      try { entry.process.kill('SIGKILL') } catch (_) {}
    }, 2000)
  }
  pool.delete(roomName)
}

function killAll() {
  pool.forEach((_, roomName) => killRoom(roomName))
}

// 应用退出时自动清理所有进程
app.on('before-quit', () => {
  console.log('[ProcessManager] 清理所有 N2N 进程...')
  killAll()
})

module.exports = { pool, getN2nDir, isRunning, killRoom, killAll }
5.4 输出解析与连接状态检测

edge 的 stdout 输出格式大概是这样的(不同版本略有差异):

20230901 12:00:01 [edge] Starting n2n edge
20230901 12:00:01 [edge] Registering with supernode [1.2.3.4:7654]
20230901 12:00:02 [edge] Registered with supernode
20230901 12:00:05 [edge] Rx 10.0.0.2 [aa:bb:cc:dd:ee:ff]

根据这些特征做状态解析:

function createOutputParser(roomName, mainWindow) {
  let connected = false
  const members = new Map()  // ip -> { mac, lastSeen }

  // 连接成功特征
  const CONNECTED_PATTERN = /Registered with supernode/i
  // 成员在线特征(收到该成员的包,说明对方在线)
  const MEMBER_PATTERN = /Rx\s+([\d.]+)\s+\[([0-9a-f:]+)\]/i
  // 连接失败特征
  const FAILED_PATTERN = /registration failed|unable to connect/i

  function parse(data) {
    const lines = data.toString().split('\n')

    for (const line of lines) {
      if (!line.trim()) continue

      // 检测连接成功
      if (!connected && CONNECTED_PATTERN.test(line)) {
        connected = true
        mainWindow.webContents.send('room-status', {
          roomName,
          status: 'connected'
        })
        console.log(`[${roomName}] 连接 supernode 成功`)
      }

      // 检测在线成员
      const memberMatch = line.match(MEMBER_PATTERN)
      if (memberMatch) {
        const ip = memberMatch[1]
        const mac = memberMatch[2]
        members.set(ip, { mac, lastSeen: Date.now() })

        // 推送最新成员列表到前端
        mainWindow.webContents.send('members-update', {
          roomName,
          members: Array.from(members.values()).map((v, i) => ({
            ip: Array.from(members.keys())[i],
            ...v
          }))
        })
      }

      // 检测连接失败
      if (FAILED_PATTERN.test(line)) {
        mainWindow.webContents.send('room-status', {
          roomName,
          status: 'failed',
          reason: line.trim()
        })
      }
    }
  }

  return { parse, getMembers: () => members, isConnected: () => connected }
}
5.5 超时检测——避免无限等待

spawn 之后如果 supernode 连不上,进程会一直挂着,用户不知道发生了什么。加个超时机制:

const CONNECT_TIMEOUT_MS = 30000  // 30秒超时

function startRoom(config, mainWindow) {
  const n2nDir = getN2nDir()

  // 先 kill 掉该房间可能存在的旧进程
  if (isRunning(config.name)) {
    killRoom(config.name)
  }

  const proc = spawn(
    path.join(n2nDir, 'edge.exe'),
    buildEdgeArgs(config),
    { cwd: n2nDir, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }
  )

  const parser = createOutputParser(config.name, mainWindow)

  // 超时定时器
  const connectTimer = setTimeout(() => {
    if (!parser.isConnected()) {
      console.warn(`[${config.name}] 连接超时,自动 kill`)
      killRoom(config.name)
      mainWindow.webContents.send('room-status', {
        roomName: config.name,
        status: 'timeout'
      })
    }
  }, CONNECT_TIMEOUT_MS)

  proc.stdout.on('data', (data) => {
    parser.parse(data)
    // 连接成功后清除超时
    if (parser.isConnected()) {
      clearTimeout(connectTimer)
    }
  })

  proc.stderr.on('data', (data) => {
    // 有些版本的 edge 把日志打到 stderr
    parser.parse(data)
  })

  proc.on('close', (code) => {
    clearTimeout(connectTimer)
    pool.delete(config.name)
    console.log(`[${config.name}] 进程退出,code=${code}`)
    mainWindow.webContents.send('room-status', {
      roomName: config.name,
      status: 'disconnected',
      exitCode: code
    })
  })

  proc.on('error', (err) => {
    clearTimeout(connectTimer)
    console.error(`[${config.name}] 进程错误:`, err)
    mainWindow.webContents.send('room-status', {
      roomName: config.name,
      status: 'error',
      reason: err.message
    })
  })

  pool.set(config.name, {
    process: proc,
    startTime: Date.now(),
    connected: false
  })

  return proc
}

module.exports = { startRoom, killRoom, killAll, isRunning }

六、IPC 通信层设计

6.1 预加载脚本(preload.js)

Electron 新版本要求开启 contextIsolation,不能直接在渲染进程里用 require,必须通过 preload 暴露安全的 API:

// preload.js
const { contextBridge, ipcRenderer } = require('electron')

contextBridge.exposeInMainWorld('electronAPI', {
  // 房间操作
  getRooms:       ()       => ipcRenderer.invoke('get-rooms'),
  saveRoom:       (config) => ipcRenderer.invoke('save-room', config),
  deleteRoom:     (name)   => ipcRenderer.invoke('delete-room', name),
  connectRoom:    (config) => ipcRenderer.invoke('connect-room', config),
  disconnectRoom: (name)   => ipcRenderer.invoke('disconnect-room', name),
  getRoomStatus:  (name)   => ipcRenderer.invoke('get-room-status', name),

  // 事件监听(主进程 -> 渲染进程)
  onRoomStatus:    (cb) => ipcRenderer.on('room-status',    (_, d) => cb(d)),
  onMembersUpdate: (cb) => ipcRenderer.on('members-update', (_, d) => cb(d)),

  // 清理监听器,防止内存泄漏
  removeRoomListeners: () => {
    ipcRenderer.removeAllListeners('room-status')
    ipcRenderer.removeAllListeners('members-update')
  }
})
6.2 IPC Handler(ipc/roomHandlers.js)
const { ipcMain } = require('electron')
const db = require('../db')
const { startRoom, killRoom, isRunning } = require('./processManager')

let mainWindow = null

function registerHandlers(win) {
  mainWindow = win

  ipcMain.handle('get-rooms', async () => {
    return await db.getRooms()
  })

  ipcMain.handle('save-room', async (_, config) => {
    await db.saveRoom(config)
    return { success: true }
  })

  ipcMain.handle('delete-room', async (_, name) => {
    if (isRunning(name)) killRoom(name)
    await db.deleteRoom(name)
    return { success: true }
  })

  ipcMain.handle('connect-room', async (_, config) => {
    try {
      startRoom(config, mainWindow)
      await db.updateLastUsed(config.name)
      return { success: true }
    } catch (err) {
      console.error('[connect-room] 失败:', err)
      return { success: false, error: err.message }
    }
  })

  ipcMain.handle('disconnect-room', async (_, name) => {
    killRoom(name)
    return { success: true }
  })

  ipcMain.handle('get-room-status', async (_, name) => {
    return { running: isRunning(name) }
  })
}

module.exports = { registerHandlers }
6.3 主进程入口(public/electron.js)
const { app, BrowserWindow } = require('electron')
const path = require('path')
const { initDB } = require('../db')
const { registerHandlers } = require('../ipc/roomHandlers')

let mainWindow

async function createWindow() {
  // 先初始化数据库
  await initDB()

  mainWindow = new BrowserWindow({
    width: 1000,
    height: 680,
    minWidth: 800,
    minHeight: 500,
    webPreferences: {
      preload: path.join(__dirname, '..', 'preload.js'),
      contextIsolation: true,   // 安全模式,必须开启
      nodeIntegration: false    // 渲染进程不直接用 Node,必须关闭
    }
  })

  // 注册 IPC 处理器
  registerHandlers(mainWindow)

  const isDev = !app.isPackaged
  if (isDev) {
    mainWindow.loadURL('http://localhost:3000')
    mainWindow.webContents.openDevTools()
  } else {
    mainWindow.loadFile(path.join(__dirname, '../build/index.html'))
  }
}

app.whenReady().then(createWindow)

app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') app.quit()
})

七、React 前端关键实现

7.1 房间状态 Hook(useRoom.js)
import { useState, useEffect, useCallback } from 'react'

export function useRoom() {
  const [rooms, setRooms] = useState([])
  const [roomStatus, setRoomStatus] = useState({})  // roomName -> status
  const [members, setMembers] = useState({})         // roomName -> members[]

  // 加载房间列表
  const loadRooms = useCallback(async () => {
    const data = await window.electronAPI.getRooms()
    setRooms(data)
  }, [])

  // 连接房间
  const connectRoom = useCallback(async (config) => {
    setRoomStatus(prev => ({ ...prev, [config.name]: 'connecting' }))
    const result = await window.electronAPI.connectRoom(config)
    if (!result.success) {
      setRoomStatus(prev => ({ ...prev, [config.name]: 'error' }))
    }
  }, [])

  // 断开房间
  const disconnectRoom = useCallback(async (name) => {
    await window.electronAPI.disconnectRoom(name)
    setRoomStatus(prev => ({ ...prev, [name]: 'disconnected' }))
    setMembers(prev => ({ ...prev, [name]: [] }))
  }, [])

  // 监听主进程推送的状态变更
  useEffect(() => {
    window.electronAPI.onRoomStatus((data) => {
      setRoomStatus(prev => ({ ...prev, [data.roomName]: data.status }))
    })

    window.electronAPI.onMembersUpdate((data) => {
      setMembers(prev => ({ ...prev, [data.roomName]: data.members }))
    })

    loadRooms()

    return () => {
      window.electronAPI.removeRoomListeners()
    }
  }, [loadRooms])

  return { rooms, roomStatus, members, loadRooms, connectRoom, disconnectRoom }
}
7.2 房间列表组件(RoomList.jsx,节选)
import React from 'react'
import { useRoom } from '../hooks/useRoom'

const STATUS_LABEL = {
  connecting:   { text: '连接中...', color: '#faad14' },
  connected:    { text: '已连接',    color: '#52c41a' },
  disconnected: { text: '未连接',    color: '#999'    },
  timeout:      { text: '连接超时',  color: '#ff4d4f' },
  error:        { text: '连接失败',  color: '#ff4d4f' },
}

export function RoomList() {
  const { rooms, roomStatus, members, connectRoom, disconnectRoom } = useRoom()

  return (
    <div className="room-list">
      {rooms.map(room => {
        const status = roomStatus[room.name] || 'disconnected'
        const { text, color } = STATUS_LABEL[status] || STATUS_LABEL.disconnected
        const roomMembers = members[room.name] || []

        return (
          <div key={room.name} className="room-card">
            <div className="room-header">
              <span className="room-name">{room.name}</span>
              <span style={{ color }}>{text}</span>
            </div>

            <div className="room-info">
              <span>虚拟IP:{room.virtual_ip}</span>
              <span>Supernode:{room.supernode}:{room.port}</span>
            </div>

            {status === 'connected' && roomMembers.length > 0 && (
              <div className="member-list">
                <div className="member-title">在线成员({roomMembers.length})</div>
                {roomMembers.map(m => (
                  <div key={m.ip} className="member-item">
                    {m.ip} <span className="mac">{m.mac}</span>
                  </div>
                ))}
              </div>
            )}

            <div className="room-actions">
              {status === 'connected' ? (
                <button onClick={() => disconnectRoom(room.name)}>断开</button>
              ) : (
                <button
                  onClick={() => connectRoom(room)}
                  disabled={status === 'connecting'}
                >
                  {status === 'connecting' ? '连接中...' : '连接'}
                </button>
              )}
            </div>
          </div>
        )
      })}
    </div>
  )
}

八、打包与发布踩坑

8.1 打包路径问题

这是最常见的坑之一。开发时 __dirname 指向源码目录,打包后路径完全不同:

//  错误:直接用 __dirname,打包后路径错误
const n2nDir = path.join(__dirname, 'n2n-bin')

//  正确:根据是否已打包动态选择路径
const n2nDir = app.isPackaged
  ? path.join(process.resourcesPath, 'n2n-bin')
  : path.join(__dirname, '..', 'n2n-bin')
8.2 把 N2N 二进制文件打进包里

package.jsonbuild.extraResources 里声明:

"extraResources": [
  {
    "from": "n2n-bin",
    "to": "n2n-bin",
    "filter": ["**/*"]
  }
]

打包后这些文件会放到 resources/n2n-bin/ 目录,通过 process.resourcesPath 访问。

8.3 Windows 上 SIGTERM 的坑

Linux/macOS 上 process.kill('SIGTERM') 一般够用,但 Windows 上 Node.js 的 SIGTERM 实现有问题,有时 kill 不干净。解决方案是改用 taskkill

const { exec } = require('child_process')

function forceKillWindows(pid) {
  exec(`taskkill /F /T /PID ${pid}`, (err) => {
    if (err) console.warn('taskkill 失败:', err.message)
  })
}

function killRoom(roomName) {
  if (!pool.has(roomName)) return
  const { process: proc } = pool.get(roomName)
  if (proc && proc.pid) {
    if (process.platform === 'win32') {
      forceKillWindows(proc.pid)
    } else {
      proc.kill('SIGTERM')
    }
  }
  pool.delete(roomName)
}

/F 表示强制,/T 表示连同子树一起 kill,避免残留子进程。


九、总结与收获

做完这个工具,技术层面最大的几点收获:

1. child_process 选型很重要
exec 适合短命令,execFile 适合不需要 shell 的短命令,spawn 才是管理长驻进程的正确姿势,fork 则用于 Node.js 子进程间通信。

2. 进程生命周期管理是核心
用 Map 做进程池,每次启动前先 kill 旧进程;应用退出时统一清理;Windows 平台用 taskkill 强制终止。这几点做好了,就不会有僵尸进程。

3. Electron IPC 安全模型
contextIsolation: true + nodeIntegration: false + preload 暴露白名单 API,这套组合是 Electron 安全的基础,不要为了方便省略。

4. 开发/生产环境路径差异
凡是涉及文件路径的地方,一定要用 app.isPackaged 做判断,否则打包后必出问题。

5. 输出解析要有容错
N2N 不同版本输出格式有差异,正则匹配要尽量宽松,同时做好异常捕获,一行解析失败不能影响整体。


工具目前在自己和朋友之间使用,联机效果稳定,延迟比 Hamachi 低不少。后续计划加上 supernode 一键部署功能,有感兴趣的朋友欢迎交流。



发帖前要善用论坛搜索功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。

Hmily 发表于 2026-3-11 11:52
I D :芈渡
邮箱: lllomh@qq.com

申请通过,欢迎光临吾爱破解论坛,期待吾爱破解有你更加精彩,ID和密码自己通过邮件密码找回功能修改,请即时登陆并修改密码!
登陆后请在一周内在此帖报道,否则将删除ID信息。
lllomh 发表于 2026-3-11 16:51
本帖最后由 lllomh 于 2026-3-18 13:04 编辑

已删除

点评

我开始时候复制的是你的标题,后来回复因为有邮箱复制的是正文的,不要改了就这样吧。  详情 回复 发表于 2026-3-11 16:54
lllomh 发表于 2026-3-11 16:52
Hmily 发表于 2026-3-11 16:54
lllomh 发表于 2026-3-11 16:51
虽然你写的芈渡,但试了实际是使用的  lllomh 才能用     id能帮我换成 芈渡  吗?     用户名  芈渡  没 ...

我开始时候复制的是你的标题,后来回复因为有邮箱复制的是正文的,不要改了就这样吧。

本版积分规则

返回列表

RSS订阅|小黑屋|处罚记录|联系我们|吾爱破解 - 52pojie.cn ( 京ICP备16042023号 | 京公网安备 11010502030087号 )

GMT+8, 2026-4-17 16:02

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表