const fs = require('fs'); const path = require('path'); const net = require('net'); const { spawn } = require('child_process'); const readline = require('readline/promises'); const { readConfig, samePath } = require('./lib/safety'); const FILES = require('./lib/files'); const { CdpClient } = require('./lib/cdp'); const { OPS } = require('./lib/ops'); const TOOL = __dirname, AITEMP = path.dirname(TOOL), LEGACY_GAME = path.dirname(AITEMP); const sleep = ms => new Promise(r => setTimeout(r, ms)); async function listening(port) { return new Promise(resolve => { const s = net.connect({ host: '127.0.0.1', port }); const finish = yes => { s.destroy(); resolve(yes); }; s.once('connect', () => finish(true)); s.once('error', () => finish(false)); s.setTimeout(1000, () => finish(false)); }); } async function panel(config, matchConfig = true) { const r = await fetch(`http://127.0.0.1:${config.webPort}/api/health`, { signal: AbortSignal.timeout(1500) }); const data = await r.json(); if (!r.ok || data.version !== 2 || !data.toolDir || !samePath(data.toolDir, TOOL) || (matchConfig && JSON.stringify(data.config) !== JSON.stringify(config))) { throw new Error('面板端口运行的是旧服务、其他版本或旧配置。请先关闭那个面板服务,再重新启动'); } return data; } async function main() { if (Number(process.versions.node.split('.')[0]) < 22) throw new Error('需要 Node.js 22 或更新版本'); const config = readConfig(path.join(TOOL, 'config.json'), { cdpPort: 9223, sandboxDir: path.join(path.parse(LEGACY_GAME).root, 'Nyaruru_shots'), webPort: 8787 }, LEGACY_GAME); const GAME = config.gameDir || LEGACY_GAME; if (process.argv.includes('--stop')) { const runtimeFile = path.join(TOOL, '.server-runtime.json'); const port = fs.existsSync(runtimeFile) ? JSON.parse(fs.readFileSync(runtimeFile, 'utf8')).port : config.webPort; await panel({ ...config, webPort: port }, false); await fetch(`http://127.0.0.1:${port}/api/shutdown`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }); console.log('控制面板服务已停止,游戏窗口保留'); return; } const runtimeFile = path.join(TOOL, '.server-runtime.json'); if (fs.existsSync(runtimeFile)) { const runtime = JSON.parse(fs.readFileSync(runtimeFile, 'utf8')); if (runtime.port !== config.webPort && await listening(runtime.port)) { await panel({ ...config, webPort: runtime.port }, false); throw new Error('旧面板仍在原端口运行,请先双击“停止面板.bat”,再重新启动'); } } console.log('咸鱼喵喵 · 截图辅助工具\n调试端口 ' + config.cdpPort + ' / 面板端口 ' + config.webPort); const panelRunning = await listening(config.webPort); if (panelRunning) await panel(config); const status = FILES.sandboxStatus(GAME, config.sandboxDir, AITEMP); let target = status.ready ? config.sandboxDir : null; if (!status.ready) { console.log('沙盒尚不可用:' + status.reason + '\n[1] 只启动面板,创建沙盒\n[2] 启动原版游戏\n[3] 退出'); const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); let choice; try { choice = (await rl.question('请选择(默认 1):')).trim(); } finally { rl.close(); } if (choice === '3') { console.log('已退出'); return; } if (choice === '2') target = GAME; else if (choice && choice !== '1') throw new Error('无效选择,已退出'); } if (target) { const occupied = await listening(config.cdpPort); if (occupied) { const client = new CdpClient(config.cdpPort); try { const info = await client.evalJson(OPS.selfPath()); if (!info.ok || !info.gameDir || !samePath(info.gameDir, target) || !info.saveDir || !samePath(info.saveDir, path.join(target, 'save'))) { throw new Error('调试端口上的游戏与目标不符。期望:' + target + ';实际:' + (info.gameDir || '未知')); } } catch (e) { throw new Error('未启动面板:不能确认已有游戏实例。' + e.message); } finally { client.close(); } } const backup = FILES.backupSaves(path.join(AITEMP, 'backups'), target, samePath(target, GAME) ? 'original' : 'sandbox'); console.log('存档已备份并校验:' + backup.name); if (!occupied) { const nw = path.join(AITEMP, 'nwjs-sdk-v0.64.1-win-x64', 'nw.exe'); if (!fs.existsSync(nw)) throw new Error('缺少 SDK 版 NW.js 0.64.1:' + nw + '\n下载:https://dl.nwjs.io/v0.64.1/nwjs-sdk-v0.64.1-win-x64.zip'); const child = spawn(nw, [target, '--remote-debugging-port=' + config.cdpPort], { cwd: path.dirname(nw), detached: true, stdio: 'ignore', windowsHide: true }); await new Promise((resolve, reject) => { child.once('spawn', resolve); child.once('error', reject); }); child.unref(); const client = new CdpClient(config.cdpPort); let ready = false; try { for (let i = 0; i < 20; i++) { try { const info = await client.evalJson(OPS.selfPath()); if (!info.ok || !info.gameDir || !samePath(info.gameDir, target) || !info.saveDir || !samePath(info.saveDir, path.join(target, 'save'))) throw new Error('实际游戏路径不符'); ready = true; break; } catch { client.close(); await sleep(500); } } } finally { client.close(); } if (!ready) throw new Error('游戏未能就绪或实例身份不符,未启动面板。请检查游戏窗口'); } console.log('当前游戏:' + target); } if (!panelRunning) { const log = fs.openSync(path.join(TOOL, 'server.log'), 'a'); const child = spawn(process.execPath, [path.join(TOOL, 'server.js')], { cwd: TOOL, detached: true, stdio: ['ignore', log, log], windowsHide: true }); await new Promise((resolve, reject) => { child.once('spawn', resolve); child.once('error', reject); }); child.unref(); fs.closeSync(log); let ready = false; for (let i = 0; i < 20; i++) { try { await panel(config); ready = true; break; } catch { await sleep(300); } } if (!ready) throw new Error('面板启动失败,请查看 tool/server.log'); } const url = `http://127.0.0.1:${config.webPort}`; const browser = spawn('rundll32.exe', ['url.dll,FileProtocolHandler', url], { detached: true, stdio: 'ignore', windowsHide: true }); browser.on('error', () => console.log('请手动打开:' + url)); browser.unref(); console.log('面板已就绪:' + url + (target ? '' : '\n本次没有启动游戏。创建沙盒后再次运行启动.bat')); } if (require.main === module) main().catch(e => { console.error('\n启动停止:' + e.message); process.exitCode = 1; }); module.exports = { main, listening, panel };