diff --git a/js/preload.js b/js/preload.js index f6bc374..c6b49dd 100644 --- a/js/preload.js +++ b/js/preload.js @@ -9,6 +9,7 @@ contextBridge.exposeInMainWorld('electronAPI', { startMq: (tendid, id) => ipcRenderer.invoke('start-mq', tendid, id), fileExists: (url) => ipcRenderer.invoke('file-exists', url), isiproxy: (url) => ipcRenderer.invoke('isiproxy', url), + getVersion: () => ipcRenderer.invoke('getVersion'), }) contextBridge.exposeInMainWorld('appUpdater', { @@ -27,4 +28,4 @@ window.addEventListener('unload', () => { ipcRenderer.removeAllListeners('update:progress'); ipcRenderer.removeAllListeners('update:downloaded'); ipcRenderer.removeAllListeners('update:error'); -}); \ No newline at end of file +}); diff --git a/js/rabbitmq-service.js b/js/rabbitmq-service.js index 31ed719..8b89304 100644 --- a/js/rabbitmq-service.js +++ b/js/rabbitmq-service.js @@ -16,6 +16,7 @@ const CFG = { }; let conn = null; +let connectionLock = null; // 🔒 连接锁,防止并发创建 let pubCh = null; // 发布 Confirm Channel let conCh = null; // 消费 Channel const emitter = new EventEmitter(); @@ -37,54 +38,80 @@ function toBuffer(payload) { // —— 内部:建立连接(含心跳、keepalive、事件) async function createConnection() { - const connection = await amqp.connect({ - protocol: CFG.protocol, - hostname: CFG.host, - port: CFG.port, - username: CFG.user, - password: CFG.pass, - vhost: CFG.vhost, - heartbeat: CFG.heartbeat, - frameMax: CFG.frameMax > 0 ? CFG.frameMax : undefined, - // 也可用 URL 形式:`amqp://u:p@host:5672/vhost?heartbeat=60` - }); + // 1. 如果已有连接,直接返回 + if (conn) return conn; - // 打开 TCP keepalive,降低 NAT/空闲超时断开的概率 - try { - const stream = connection.stream || connection.socket; - if (stream?.setKeepAlive) stream.setKeepAlive(true, 15_000); // 15s - } catch (_) { } + // 2. 如果正在连接中,等待它完成 + if (connectionLock) { + return connectionLock; + } - connection.on('error', (e) => { - // 心跳超时常见,避免重复噪音 - const msg = e?.message || String(e); - if (msg && /heartbeat/i.test(msg)) { - console.error('[AMQP] 连接错误 (心跳):', msg); - } else { - console.error('[AMQP] 连接错误:', msg); + // 3. 开始新连接,加锁 + connectionLock = (async () => { + try { + console.log(`[AMQP] 开始连接 ${CFG.host}...`); + const connection = await amqp.connect({ + protocol: CFG.protocol, + hostname: CFG.host, + port: CFG.port, + username: CFG.user, + password: CFG.pass, + vhost: CFG.vhost, + heartbeat: CFG.heartbeat, + frameMax: CFG.frameMax > 0 ? CFG.frameMax : undefined, + }); + + // 如果在连接过程中被要求关闭(race condition),则立刻关闭并抛错 + if (closing) { + console.warn('[AMQP] 连接刚建立但 Detected closing=true, closing now...'); + connection.close().catch(() => { }); + throw new Error('Connection aborted (closing)'); + } + + // 打开 TCP keepalive + try { + const stream = connection.stream || connection.socket; + if (stream?.setKeepAlive) stream.setKeepAlive(true, 15_000); + } catch (_) { } + + connection.on('error', (e) => { + const msg = e?.message || String(e); + if (msg && /heartbeat/i.test(msg)) { + console.error('[AMQP] 连接错误 (心跳):', msg); + } else { + console.error('[AMQP] 连接错误:', msg); + } + emitter.emit('error', e); + }); + + connection.on('close', () => { + if (closing) return; + console.error('[AMQP] 连接已关闭'); + conn = null; pubCh = null; conCh = null; + scheduleReconnect(); + }); + + connection.on('blocked', (reason) => { + console.warn('[AMQP] 连接被代理阻塞::', reason); + emitter.emit('blocked', reason); + }); + connection.on('unblocked', () => { + console.log('[AMQP] 链接解锁'); + emitter.emit('unblocked'); + }); + + console.log(`[AMQP] 已连接到 ${CFG.host} (hb=${CFG.heartbeat}s)`); + conn = connection; // ✅ 赋值给全局变量 + return connection; + } catch (err) { + console.error('[AMQP] createConnection failed:', err.message); + throw err; + } finally { + connectionLock = null; // 🔓 解锁 } - emitter.emit('error', e); - }); + })(); - connection.on('close', () => { - if (closing) return; // 正在关闭时不重连 - console.error('[AMQP] 连接已关闭'); - conn = null; pubCh = null; conCh = null; - scheduleReconnect(); - }); - - // Broker 侧内存/磁盘压力会 block 连接 - connection.on('blocked', (reason) => { - console.warn('[AMQP] 连接被代理阻塞::', reason); - emitter.emit('blocked', reason); - }); - connection.on('unblocked', () => { - console.log('[AMQP] 链接解锁'); - emitter.emit('unblocked'); - }); - - console.log(`[AMQP] 已连接到 ${CFG.host} (hb=${CFG.heartbeat}s)`); - return connection; + return connectionLock; } // —— 内部:确保连接和通道存在 @@ -252,6 +279,7 @@ async function publishToExchange(exchange, routingKey, payload, options = {}) { persistent = true, headers = {}, mandatory = false, + // mandatory = false, } = options; if (assertExchange) { @@ -267,6 +295,9 @@ async function publishToExchange(exchange, routingKey, payload, options = {}) { async function reconnectNow() { if (closing) return; if (reconnecting) return; + // 如果正在连接,也视为正在重连/忙碌,稍后 + if (connectionLock) return; + try { if (pubCh) await pubCh.close().catch(() => { }); if (conCh) await conCh.close().catch(() => { }); @@ -281,6 +312,18 @@ async function reconnectNow() { async function close() { closing = true; if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } + + // 关键修复:如果正在建立连接,必须等待它完成(或失败),再执行关闭 + // 否则 amqplib 的 "socket closed abruptly" 错误会频发 + if (connectionLock) { + try { + console.log('[AMQP] close() called while connecting, waiting...'); + await connectionLock; + } catch (_) { + // 忽略连接失败 + } + } + try { // 取消所有消费者 for (const [q, c] of consumers.entries()) { @@ -298,6 +341,14 @@ async function close() { } // —— 进程信号(可选) +async function open() { + closing = false; + reconnecting = false; + if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } + backoff = 1000; + await ensureChannels(); +} + process.once('SIGINT', async () => { try { await close(); } finally { process.exit(0); } }); process.once('SIGTERM', async () => { try { await close(); } finally { process.exit(0); } }); @@ -305,6 +356,7 @@ module.exports = { startConsumer, publishToQueue, publishToExchange, + open, reconnectNow, close, emitter, // 可订阅 'message' / 'handlerError' / 'reconnected' / 'error' / 'blocked' / 'unblocked' diff --git a/main.js b/main.js index ead011c..2f1b22a 100644 --- a/main.js +++ b/main.js @@ -2,6 +2,7 @@ const { app, globalShortcut, BrowserWindow, net, dialog, ipcMain } = require('el const { startSSE } = require('./js/sse-server'); const { createBurstBroadcaster } = require('./js/burst-broadcast'); const mq = require('./js/rabbitmq-service'); +const https = require('https') const axios = require('axios'); const os = require('os'); const fsp = require('node:fs/promises') @@ -27,6 +28,10 @@ const LOCAL_HTTPS_WHITELIST = [ ] let userData = { tenantId: null, userId: null } +let mqEnabled = true; +let mqActive = false; +const mqQueueEnabled = { crawler: true, boss: true }; +const mqConsumers = new Map(); const { exec, spawn, execFile } = require('child_process'); // 如果你用 exec 启动 scrcpy,保留此行 // app.commandLine.appendSwitch('remote-debugging-port', '9222'); //远程控制台端口F12 @@ -309,46 +314,69 @@ function dumpAllMem() { // 在函数外定义计数器(或者放在函数内部,用闭包封装) let consumeCount = 0; -async function setupMQConsumerAndPublisher(emitMessage, tenantId) { - const queueNames = [ - `q.tenant.${tenantId}`, - `b.tenant.${tenantId}`, // 新增队列 - ]; +function syncMqEnabled() { + mqEnabled = Boolean(mqQueueEnabled.crawler || mqQueueEnabled.boss); +} - for (const qName of queueNames) { - await mq.startConsumer( - qName, - async (msg) => { - const payload = msg.json ?? msg.text; // 原始业务数据 - consumeCount++; // 所有队列共用计数器 +function getQueueNameByType(type, tenantId) { + if (type === 'crawler') return `q.tenant.${tenantId}`; + if (type === 'boss') return `b.tenant.${tenantId}`; + return null; +} - // 标记来源类型:普通队列 / burst 队列 - const isBurstQueue = qName.startsWith('b.'); - const meta = isBurstQueue ? 2 : 1; +async function startMQConsumer(queueName, emitMessage) { + if (!queueName || mqConsumers.has(queueName)) return; + const consumer = await mq.startConsumer( + queueName, + async (msg) => { + const payload = msg.json ?? msg.text; + consumeCount++; - console.log( - `[MQ消费] [${qName}]`, - payload?.hostsId, - payload?.country, - '共' + consumeCount + '条数据' - ); + const isBurstQueue = queueName.startsWith('b.'); + const meta = isBurstQueue ? 2 : 1; - // ⚠️ 关键:在原有 payload 的基础上,增加 _mqMeta 字段 - // 这样你之前前端用 payload.hostsId / payload.country 的地方完全不用改 - const wrapped = { - ...payload, - _mqMeta: meta - }; + console.log( + `[MQ] [${queueName}]`, + payload?.hostsId, + payload?.country, + 'count=', + consumeCount + ); - // 广播到前端 - emitMessage(wrapped); - // 成功返回会在 mq 客户端内部自动 ack - }, - { prefetch: 1, requeueOnError: false, durable: true, assertQueue: true } - ); + const wrapped = { + ...payload, + _mqMeta: meta + }; + + emitMessage(wrapped); + }, + { prefetch: 1, requeueOnError: false, durable: true, assertQueue: true } + ); + mqConsumers.set(queueName, consumer); +} + +async function stopMQConsumer(queueName) { + const consumer = mqConsumers.get(queueName); + if (!consumer) { + console.warn('[MQ] stopMQConsumer: no consumer for', queueName); + return; } + if (consumer?.stop) { + await consumer.stop().catch(() => { }); + } + mqConsumers.delete(queueName); +} +async function setupMQConsumerAndPublisher(emitMessage, tenantId) { + syncMqEnabled(); + if (!mqEnabled) return; + + for (const type of Object.keys(mqQueueEnabled)) { + if (!mqQueueEnabled[type]) continue; + const qName = getQueueNameByType(type, tenantId); + await startMQConsumer(qName, emitMessage); + } + mqActive = mqConsumers.size > 0; - // 供渲染进程发送消息到队列(保持原来的 q.tenant.* 不变) ipcMain.removeHandler('mq-send'); ipcMain.handle('mq-send', async (_event, user) => { console.log('消息已发送', user); @@ -409,8 +437,10 @@ function createWindow() { // 自动判断环境使用不同的页面地址 const isProd = app.isPackaged; - // const targetURL = isProd ? 'https://iosaitest.yolozs.com' : 'http://192.168.1.128:8080'; - const targetURL = isProd ? 'https://iosai.yolozs.com' : 'http://192.168.1.128:8080'; + // const targetURL = isProd ? 'https://iosaitest.yolozs.com' : 'http://192.168.2.128:8081'; + const targetURL = isProd ? 'https://iosai.yolozs.com' : 'http://192.168.2.128:8080'; + // const targetURL = 'https://iosai.yolozs.com'; + // const targetURL = 'http://192.168.2.128:8080'; console.log('[页面加载] 使用地址:', targetURL); let retryIntervalId = null; @@ -502,7 +532,10 @@ function createWindow() { } }); + ipcMain.handle('getVersion', async (_event, opts = {}) => { + return app.getVersion(); + }); // 仅检查固定路径:C:\Users\Administrator\IOSAI\aiConfig.json ipcMain.handle('file-exists', async () => { @@ -602,10 +635,19 @@ function createWindow() { // 如果没有把加载页文件放到指定位置,给个兜底 win.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent('

正在等待 iproxy.exe 启动…

')); } - // ✅ 不再自动检测 iproxy,3 秒后直接进入业务页面 - setTimeout(() => { - tryNavigate('delay-3s-autogo'); - }, 3000); + // 🆕 先在等待页面做虚拟机检测:如果是虚拟机 → 弹窗 + 退出 + (async () => { + const isVM = await detectVMAndHandle(win); + if (isVM) { + // detectVMAndHandle 里已经 app.quit(),这里直接返回 + return; + } + + // 非虚拟机:3 秒后正常进入业务页面 + setTimeout(() => { + tryNavigate('delay-3s-autogo'); + }, 3000); + })(); // // 开始检测 iproxy,检测到后再跳转 targetURL // waitForIproxyAndNavigate(win, targetURL, { // intervalMs: 2000, @@ -631,9 +673,88 @@ function createWindow() { ipcMain.handle('start-mq', async (event, tentId, id) => { userData.tenantId = tentId; userData.userId = id; - //启动mq - setupMQConsumerAndPublisher(emitMessage, tentId) + syncMqEnabled(); + if (!mqEnabled) { + console.log('[MQ] start-mq skipped because disabled'); + return { ok: true, enabled: false }; + } + if (mq.open) { + await mq.open(); + } + await setupMQConsumerAndPublisher(emitMessage, tentId); + mqActive = mqConsumers.size > 0; + return { ok: true, enabled: mqActive }; + }); + + ipcMain.handle('open-mq', async (_event, payload) => { + console.log('[MQ] open-mq', payload, 'tenantId=', userData.tenantId, 'consumers=', [...mqConsumers.keys()]); + + if (typeof payload == 'boolean') { + mqQueueEnabled.crawler = payload; + mqQueueEnabled.boss = payload; + syncMqEnabled(); + + if (payload) { + if (!userData.tenantId) { + return { ok: false, error: 'tenantId missing' }; + } + if (mq.open) { + await mq.open(); + } + await setupMQConsumerAndPublisher(emitMessage, userData.tenantId); + mqActive = mqConsumers.size > 0; + return { ok: true, enabled: mqEnabled }; + } + + await mq.close().catch((err) => { + console.warn('[MQ] close failed', err); + }); + mqConsumers.clear(); + mqActive = false; + return { ok: true, enabled: false }; + } + + const type = payload?.type; + const shouldEnable = payload?.enable === true; + if (!type) { + return { ok: false, error: 'type missing' }; + } + if (type !== 'crawler' && type !== 'boss') { + return { ok: false, error: 'type invalid' }; + } + if (payload?.enable !== true && payload?.enable !== false) { + return { ok: false, error: 'enable invalid' }; + } + + mqQueueEnabled[type] = shouldEnable; + syncMqEnabled(); + + if (!userData.tenantId) { + return { ok: false, error: 'tenantId missing' }; + } + + const qName = getQueueNameByType(type, userData.tenantId); + if (shouldEnable) { + if (mq.open) { + await mq.open(); + } + await startMQConsumer(qName, emitMessage); + } else { + await stopMQConsumer(qName); + } + + if (!mqEnabled) { + await mq.close().catch((err) => { + console.warn('[MQ] close failed', err); + }); + mqConsumers.clear(); + mqActive = false; + } else { + mqActive = mqConsumers.size > 0; + } + + return { ok: true, enabled: mqEnabled, queue: type, queueEnabled: mqQueueEnabled[type] }; }); // 可选:开发阶段打开 DevTools F12 // win.webContents.openDevTools(); @@ -780,18 +901,25 @@ function startIOSAIExecutable() { async function killIOSAI() { console.log('尝试关闭IOSAI', userData); // console.log('axios', axios); + const httpsAgent = new https.Agent({ + family: 4 // 强制使用 IPv4 + }) - await axios.post('https://crawlclient.api.yolozs.com/api/user/aiChat-logout', { userId: userData.userId, tenantId: userData.tenantId }, { - headers: { - 'Content-Type': 'application/json', // 设置请求头 - 'vvtoken': userData.tokenValue + await axios.post( + 'https://crawlclient.api.yolozs.com/api/user/aiChat-logout', + { userId: userData.userId, tenantId: userData.tenantId }, + { + headers: { + 'Content-Type': 'application/json', + 'vvtoken': userData.tokenValue + }, + httpsAgent, + timeout: 5000 // 建议加个超时时间,避免死卡 } - }).then(response => { - console.log("发送登出请求成功") - }).catch(error => { - console.log("发送登出请求错误", error) - }).finally(error => { - // console.log("发送") + ).then(res => { + console.log('发送登出请求成功') + }).catch(err => { + console.log('发送登出请求错误', err) }) try { @@ -896,4 +1024,125 @@ function normalizePath(targetPath, baseDir) { return path.resolve(base, targetPath) } return path.resolve(targetPath) -} \ No newline at end of file +} + + +// ======== 虚拟机检测相关工具函数(Windows 为主)======== + +// CPU 型号检测(同步) +function isVmByCpu() { + try { + const cpus = os.cpus(); + if (!cpus || !cpus.length) return false; + const model = (cpus[0].model || '').toLowerCase(); + const vmKeywords = [ + 'virtualbox', + 'vmware', + 'kvm', + 'qemu', + 'hyper-v', + 'xen', + 'parallels' + ]; + return vmKeywords.some(k => model.includes(k)); + } catch (e) { + console.warn('[VM检测] CPU 检测失败:', e); + return false; + } +} + +// MAC 前缀检测(同步) +function isVmByMac() { + try { + const ifaces = os.networkInterfaces(); + const vmMacPrefixes = [ + '00:05:69', '00:0c:29', '00:1c:14', '00:50:56', // VMware + '08:00:27', // VirtualBox + '00:15:5d', // Hyper-V + ]; + for (const name in ifaces) { + for (const detail of ifaces[name]) { + const mac = (detail.mac || '').toLowerCase(); + if (!mac || mac === '00:00:00:00:00:00') continue; + if (vmMacPrefixes.some(p => mac.startsWith(p))) { + return true; + } + } + } + return false; + } catch (e) { + console.warn('[VM检测] MAC 检测失败:', e); + return false; + } +} + +// WMI 读取制造商(异步,仅 Windows) +function isVmByWMI() { + return new Promise((resolve) => { + if (process.platform !== 'win32') return resolve(false); + exec('wmic computersystem get manufacturer', { windowsHide: true }, (err, stdout = '') => { + if (err) { + console.warn('[VM检测] WMI 检测失败:', err); + return resolve(false); + } + const txt = stdout.toLowerCase(); + const vmKeywords = [ + 'vmware', + 'virtualbox', + 'microsoft corporation', // Hyper-V 通常是这个 + 'qemu', + 'xen', + 'parallels' + ]; + const hit = vmKeywords.some(k => txt.includes(k)); + resolve(hit); + }); + }); +} + +// 综合判断:返回 Promise +async function isVirtualMachine() { + try { + const byCpu = isVmByCpu(); + const byMac = isVmByMac(); + const byWmi = await isVmByWMI(); + + const hits = [byCpu, byMac, byWmi].filter(Boolean).length; + + // 规则:命中 2 项以上,或者 WMI 单独命中,就认为是虚拟机 + const isVM = hits >= 2 || byWmi; + + console.log('[VM检测] 结果:', { byCpu, byMac, byWmi, hits, isVM }); + return isVM; + } catch (e) { + console.warn('[VM检测] 检测异常,放行运行:', e); + return false; + } +} + +// 检测并处理:如果是虚拟机 → 弹窗 + 退出 +async function detectVMAndHandle(win) { + // 只在 Windows 做严格限制,其他平台按需放行 + if (process.platform !== 'win32') return false; + + const isVM = await isVirtualMachine(); + if (!isVM) return false; + + console.warn('[VM检测] 检测到虚拟机环境,准备退出应用'); + + try { + await dialog.showMessageBox(win || null, { + type: 'error', + title: '运行环境异常', + message: '检测到程序运行于虚拟机环境,出于安全策略,本程序将退出。', + buttons: ['确定'], + defaultId: 0, + noLink: true + }); + } catch (e) { + console.warn('[VM检测] 弹窗失败,直接退出:', e); + } + + app.quit(); + return true; +} diff --git a/main/index.js b/main/index.js deleted file mode 100644 index be927ba..0000000 --- a/main/index.js +++ /dev/null @@ -1,61 +0,0 @@ -// main/index.js -const { app, globalShortcut, BrowserWindow } = require('electron') -const path = require('path') - -const { registerUpdater, updaterState } = require('./updater') -const { createMainWindow } = require('./window') -const { registerSystemIpc } = require('./ipc/system') -const { registerFileIpc } = require('./ipc/files') -const { registerMqIpc } = require('./ipc/mq') -const { startIOSAIExecutable, killIOSAI } = require('./services/iosai') -const { startSSE } = require('./services/sse') -const { setupGuards } = require('./utils/guard') -const { dumpAllMem } = require('./utils/mem') - -setupGuards() -app.commandLine.appendSwitch('enable-precise-memory-info') -app.commandLine.appendSwitch('js-flags', '--expose-gc --max-old-space-size=4096') -app.commandLine.appendSwitch('enable-experimental-web-platform-features') -app.commandLine.appendSwitch('enable-features', 'WebCodecs,MediaStreamInsertableStreams') - -let sse = null - -app.on('ready', async () => { - // 更新 - registerUpdater() - - // 启动外部服务 - startIOSAIExecutable() - - // IPC - registerSystemIpc() - registerFileIpc() - registerMqIpc((payload) => sse?.broadcast('message', payload)) // 渲染用 - - // SSE - sse = startSSE() - - // 窗口 - const win = createMainWindow({ updaterState }) - - // 快捷键 - globalShortcut.register('Control+Shift+I', () => { - const w = BrowserWindow.getFocusedWindow() - if (!w) return - const wc = w.webContents - wc.isDevToolsOpened() ? wc.closeDevTools() : wc.openDevTools({ mode: 'detach' }) - }) - - // 内存Dump(可选) - setInterval(dumpAllMem, 5000) -}) - -app.on('will-quit', () => globalShortcut.unregisterAll()) -app.on('before-quit', async () => { await killIOSAI() }) -app.on('window-all-closed', async () => { - await killIOSAI() - if (process.platform !== 'darwin') app.quit() -}) -app.on('activate', () => { - if (BrowserWindow.getAllWindows().length === 0) createMainWindow({ updaterState }) -}) diff --git a/main/ipc/files.js b/main/ipc/files.js deleted file mode 100644 index 53eab6f..0000000 --- a/main/ipc/files.js +++ /dev/null @@ -1,24 +0,0 @@ -// main/ipc/files.js -const { ipcMain } = require('electron') -const fsp = require('node:fs/promises') -const { normalizePath } = require('../utils/paths') - -function registerFileIpc() { - ipcMain.removeHandler('file-exists') - ipcMain.handle('file-exists', async (_evt, targetPath, baseDir) => { - try { - const full = normalizePath(targetPath, baseDir) - const stat = await fsp.stat(full).catch(err => (err?.code === 'ENOENT' ? null : Promise.reject(err))) - if (!stat) return { ok: true, exists: false, path: full } - return { - ok: true, exists: true, path: full, - isFile: stat.isFile(), isDirectory: stat.isDirectory(), - size: stat.size, mtimeMs: stat.mtimeMs - } - } catch (e) { - return { ok: false, error: e.message || String(e) } - } - }) -} - -module.exports = { registerFileIpc } diff --git a/main/ipc/mq.js b/main/ipc/mq.js deleted file mode 100644 index 0c88014..0000000 --- a/main/ipc/mq.js +++ /dev/null @@ -1,30 +0,0 @@ -// main/ipc/mq.js -const { ipcMain } = require('electron') -const mq = require('../../js/rabbitmq-service') // 复用你的文件 -let currentTenantId = null - -async function startConsumer(emitMessage, tenantId) { - await mq.startConsumer( - `q.tenant.${tenantId}`, - (msg) => emitMessage(msg.json ?? msg.text), - { prefetch: 1, requeueOnError: false, durable: true, assertQueue: true } - ) -} - -function registerMqIpc(emitMessage) { - ipcMain.removeHandler('start-mq') - ipcMain.handle('start-mq', async (_event, tenantId, userId) => { - currentTenantId = tenantId - await startConsumer(emitMessage, tenantId) - return { ok: true } - }) - - ipcMain.removeHandler('mq-send') - ipcMain.handle('mq-send', async (_event, payload) => { - if (!currentTenantId) return { ok: false, error: 'tenant not set' } - await mq.publishToQueue(`q.tenant.${currentTenantId}`, payload) - return { ok: true } - }) -} - -module.exports = { registerMqIpc } diff --git a/main/ipc/system.js b/main/ipc/system.js deleted file mode 100644 index ab8ff9a..0000000 --- a/main/ipc/system.js +++ /dev/null @@ -1,17 +0,0 @@ -// main/ipc/system.js -const { ipcMain, dialog } = require('electron') - -function registerSystemIpc() { - ipcMain.removeHandler('manual-gc') - ipcMain.handle('manual-gc', () => { - if (global.gc) { - global.gc() - console.log('🧹 手动触发 GC 成功') - } else { - console.warn('⚠️ global.gc 不存在,请确认 --expose-gc') - } - }) - // 你也可以把 select-file 放这里(如果不想放 window.js) -} - -module.exports = { registerSystemIpc } diff --git a/main/preload.js b/main/preload.js deleted file mode 100644 index f6bc374..0000000 --- a/main/preload.js +++ /dev/null @@ -1,30 +0,0 @@ -// preload.js -const { contextBridge, ipcRenderer } = require('electron') - -contextBridge.exposeInMainWorld('electronAPI', { - selectApkFile: () => ipcRenderer.invoke('select-apk-file'), - selectFile: () => ipcRenderer.invoke('select-file'), - manualGc: () => ipcRenderer.invoke('manual-gc'), - mqSend: (arg) => ipcRenderer.invoke('mq-send', arg), - startMq: (tendid, id) => ipcRenderer.invoke('start-mq', tendid, id), - fileExists: (url) => ipcRenderer.invoke('file-exists', url), - isiproxy: (url) => ipcRenderer.invoke('isiproxy', url), -}) - -contextBridge.exposeInMainWorld('appUpdater', { - onAvailable: (cb) => ipcRenderer.on('update:available', (_e, info) => cb(info)), - onProgress: (cb) => ipcRenderer.on('update:progress', (_e, p) => cb(p)), - onDownloaded: (cb) => ipcRenderer.on('update:downloaded', (_e, info) => cb(info)), - onError: (cb) => ipcRenderer.on('update:error', (_e, err) => cb(err)), - // 主动触发 - checkNow: () => ipcRenderer.invoke('update:checkNow'), - quitAndInstallNow: () => ipcRenderer.invoke('update:quitAndInstallNow'), -}); - -// 页面卸载时清理监听(可选) -window.addEventListener('unload', () => { - ipcRenderer.removeAllListeners('update:available'); - ipcRenderer.removeAllListeners('update:progress'); - ipcRenderer.removeAllListeners('update:downloaded'); - ipcRenderer.removeAllListeners('update:error'); -}); \ No newline at end of file diff --git a/main/services/iosai.js b/main/services/iosai.js deleted file mode 100644 index acfebda..0000000 --- a/main/services/iosai.js +++ /dev/null @@ -1,78 +0,0 @@ -// main/services/iosai.js -const { app } = require('electron') -const path = require('path') -const fs = require('fs') -const { exec } = require('child_process') -const axios = require('axios') - -let iosAIProcess = null -let userData = { tenantId: null, userId: null, tokenValue: null } // 如果需要共享,请提供 setter - -function resolveIOSAIPath() { - const candidates = [ - path.join(path.dirname(process.execPath), 'iOSAI', 'IOSAI.exe'), - path.join(process.resourcesPath || '', 'iOSAI', 'IOSAI.exe'), - path.join(__dirname, '..', 'iOSAI', 'IOSAI.exe'), - path.join(process.cwd(), 'iOSAI', 'IOSAI.exe'), - ] - for (const p of candidates) { - try { if (fs.existsSync(p)) return p } catch { } - } - return null -} - -function startIOSAIExecutable() { - if (process.platform !== 'win32') { - console.warn('[IOSAI] 非 Windows,跳过') - return - } - const exePath = resolveIOSAIPath() - if (!exePath) return console.error('[IOSAI] 未找到 IOSAI.exe') - - const exeDir = path.dirname(exePath) - const exeFile = path.basename(exePath) - const cmd = `start "" /D "${exeDir}" "${exeFile}"` - - try { - exec(cmd, { cwd: exeDir, windowsHide: false }, (err) => { - if (err) console.error('[IOSAI] 启动失败:', err) - else console.log('[IOSAI] 启动命令已执行') - }) - } catch (e) { console.error('[IOSAI] 启动异常:', e) } -} - -async function killIOSAI() { - try { - if (userData.userId && userData.tenantId) { - await axios.post('https://crawlclient.api.yolozs.com/api/user/aiChat-logout', - { userId: userData.userId, tenantId: userData.tenantId }, - { headers: { 'Content-Type': 'application/json', 'vvtoken': userData.tokenValue } } - ).catch(e => console.log('[IOSAI] 登出失败', e?.message)) - } - if (iosAIProcess?.pid) { - exec(`taskkill /PID ${iosAIProcess.pid} /T /F`, (err) => { - if (err) { - exec('taskkill /IM IOSAI.exe /F') - } - }) - } else { - exec('taskkill /IM IOSAI.exe /F') - exec('taskkill /IM iproxy.exe /F') - exec('taskkill /IM tidevice.exe /F') - } - } catch { } - iosAIProcess = null -} - -function isProcessRunningWin(exeName) { - return new Promise((resolve) => { - if (process.platform !== 'win32') return resolve(true) - const cmd = `tasklist /FI "IMAGENAME eq ${exeName}" /FO CSV /NH` - exec(cmd, { windowsHide: true }, (err, stdout) => { - if (err || !stdout) return resolve(false) - resolve(stdout.toLowerCase().includes(`"${exeName.toLowerCase()}"`)) - }) - }) -} - -module.exports = { startIOSAIExecutable, killIOSAI, isProcessRunningWin } diff --git a/main/services/sse.js b/main/services/sse.js deleted file mode 100644 index f18837d..0000000 --- a/main/services/sse.js +++ /dev/null @@ -1,20 +0,0 @@ -// main/services/sse.js -const { startSSE } = require('../../js/sse-server') -const { createBurstBroadcaster } = require('../../js/burst-broadcast') - -function start() { - const sseServer = startSSE() - const broadcast = createBurstBroadcaster(sseServer.broadcast, { - event: 'message', - idleMs: 10_000, - startPayload: 'start', - startOnFirst: true - }) - // 返回一个统一接口 - return { - broadcast: (event, payload) => sseServer.broadcast(event, payload), - burst: broadcast, - } -} - -module.exports = { startSSE: start } diff --git a/main/updater.js b/main/updater.js deleted file mode 100644 index 0a63a1b..0000000 --- a/main/updater.js +++ /dev/null @@ -1,69 +0,0 @@ -// main/updater.js -const { app, BrowserWindow, ipcMain } = require('electron') -const { autoUpdater } = require('electron-updater') -const log = require('electron-log') -Object.assign(console, log.functions) - -const updaterState = { - updateInProgress: false, - updateDownloaded: false, - pendingNavs: [], -} -function flushPendingNavs() { - const fns = updaterState.pendingNavs.slice() - updaterState.pendingNavs = [] - for (const fn of fns) try { fn() } catch (e) { console.warn('[Nav defer err]', e) } -} -function broadcast(channel, payload) { - BrowserWindow.getAllWindows().forEach(w => !w.isDestroyed() && w.webContents.send(channel, payload)) -} - -function registerUpdater() { - autoUpdater.logger = log - autoUpdater.logger.transports.file.level = 'info' - autoUpdater.autoDownload = true - autoUpdater.autoInstallOnAppQuit = false - - autoUpdater.on('checking-for-update', () => console.log('[updater] checking...')) - autoUpdater.on('update-available', (info) => { - updaterState.updateInProgress = true - console.log('[updater] available', info.version) - broadcast('update:available', info) - }) - autoUpdater.on('update-not-available', () => { - console.log('[updater] not-available') - updaterState.updateInProgress = false - flushPendingNavs() - }) - let lastSend = 0 - autoUpdater.on('download-progress', (p) => { - updaterState.updateInProgress = true - const now = Date.now() - if (now - lastSend > 150) { - lastSend = now - broadcast('update:progress', p) - } - const win = BrowserWindow.getAllWindows()[0] - if (win) win.setProgressBar(p.percent / 100) - }) - autoUpdater.on('update-downloaded', (info) => { - console.log('[updater] downloaded') - updaterState.updateInProgress = true - updaterState.updateDownloaded = true - broadcast('update:downloaded', info) - // 想要自动安装:autoUpdater.quitAndInstall(false, true) - }) - autoUpdater.on('error', (err) => { - console.error('[updater] error', err) - broadcast('update:error', { message: String(err) }) - updaterState.updateInProgress = false - flushPendingNavs() - }) - - ipcMain.handle('update:quitAndInstall', () => autoUpdater.quitAndInstall(false, true)) - ipcMain.handle('update:checkNow', () => autoUpdater.checkForUpdates()) - - autoUpdater.checkForUpdates() -} - -module.exports = { registerUpdater, updaterState } diff --git a/main/utils/guard.js b/main/utils/guard.js deleted file mode 100644 index 7480824..0000000 --- a/main/utils/guard.js +++ /dev/null @@ -1,11 +0,0 @@ -// main/utils/guard.js -const { app } = require('electron') - -function setupGuards() { - process.on('uncaughtException', (error) => console.error('uncaughtException:', error)) - process.on('unhandledRejection', (reason) => console.error('unhandledRejection:', reason)) - app.on('web-contents-created', (_, contents) => { - contents.on('render-process-gone', (_e, details) => console.error('渲染崩溃:', details)) - }) -} -module.exports = { setupGuards } diff --git a/main/utils/mem.js b/main/utils/mem.js deleted file mode 100644 index 94735c2..0000000 --- a/main/utils/mem.js +++ /dev/null @@ -1,25 +0,0 @@ -// main/utils/mem.js -const { app } = require('electron') - -function toMB(v) { - if (!v || v <= 0) return 0 - const kbToMB = v / 1024 - if (kbToMB > 1) return Number(kbToMB.toFixed(1)) - return Number(v.toFixed(1)) -} -function dumpAllMem() { - try { - const metrics = app.getAppMetrics() - const report = metrics.map(m => { - const mem = m.memory || {} - const workingSetMB = toMB(mem.workingSetSize ?? mem.workingSet ?? 0) - const privateMB = toMB(mem.privateBytes ?? mem.private ?? 0) - const sharedMB = toMB(mem.shared ?? 0) - return { pid: m.pid, type: m.type, workingSetMB, privateMB, sharedMB } - }) - // console.log(report) - } catch (e) { - console.warn('getAppMetrics error:', e) - } -} -module.exports = { toMB, dumpAllMem } diff --git a/main/utils/paths.js b/main/utils/paths.js deleted file mode 100644 index a960dc6..0000000 --- a/main/utils/paths.js +++ /dev/null @@ -1,21 +0,0 @@ -// main/utils/paths.js -const path = require('path') -const { fileURLToPath } = require('node:url') -const { app } = require('electron') - -function normalizePath(targetPath, baseDir) { - if (typeof targetPath !== 'string' || !targetPath.trim()) throw new Error('无效的路径参数') - if (targetPath.startsWith('file://')) return fileURLToPath(new URL(targetPath)) - if (!path.isAbsolute(targetPath)) { - const base = baseDir && typeof baseDir === 'string' ? baseDir : process.cwd() - return path.resolve(base, targetPath) - } - return path.resolve(targetPath) -} - -function resolveResource(relPath) { - const base = app.isPackaged ? process.resourcesPath : path.resolve(__dirname, '..') - return path.join(base, relPath) -} - -module.exports = { normalizePath, resolveResource } diff --git a/main/window.js b/main/window.js deleted file mode 100644 index 2b5e5bf..0000000 --- a/main/window.js +++ /dev/null @@ -1,96 +0,0 @@ -// main/window.js -const { app, BrowserWindow, dialog, ipcMain } = require('electron') -const path = require('path') -const fs = require('fs') -const { isProcessRunningWin } = require('./services/iosai') - -function safeLoadURL(win, url, onFail) { - if (!win || win.isDestroyed()) return - win.loadURL(url).catch(err => { console.warn('[loadURL fail]', err); onFail?.(err) }) -} - -function createMainWindow({ updaterState }) { - const win = new BrowserWindow({ - width: 1920, - height: 1080, - title: `YOLO(AI助手ios) v${app.getVersion()}`, - frame: true, - webPreferences: { - preload: path.join(__dirname, 'preload.js'), - contextIsolation: true, - nodeIntegration: false, - sandbox: true, - webSecurity: true, - backgroundThrottling: false, - enableRemoteModule: false, - offscreen: false, - experimentalFeatures: true, - autoplayPolicy: 'no-user-gesture-required', - devTools: true, - } - }) - win.setMenu(null) - win.maximize() - - const isProd = app.isPackaged - const targetURL = isProd ? 'https://iosai.yolozs.com' : 'http://192.168.1.128:8080' - console.log('[page] target:', targetURL) - - const tryNavigate = (reason = '') => { - const ts = Date.now() - const go = () => safeLoadURL(win, `${targetURL}?t=${ts}`) - if (updaterState.updateInProgress) { - console.log(`[Nav] blocked (${reason}): updating...`) - updaterState.pendingNavs.push(go) - return - } - go() - } - - // 等待页 - const loadingFile = path.join(__dirname, '..', 'waiting.html') - if (process.platform === 'win32') { - if (fs.existsSync(loadingFile)) { - win.loadFile(loadingFile).catch(() => win.loadURL('data:text/html,

正在等待 iproxy.exe 启动…

')) - } else { - win.loadURL('data:text/html,

正在等待 iproxy.exe 启动…

') - } - waitForProcessAndNavigate(win, targetURL, { exeName: 'iproxy.exe' }, tryNavigate) - } else { - tryNavigate('non-win-first') - } - - // 选择文件对话框(放这也行,但我在 system.js 里也提供了封装) - ipcMain.handle('select-apk-file', async () => { - const r = await dialog.showOpenDialog({ title: '选择 APK 文件', filters: [{ name: 'APK', extensions: ['apk'] }], properties: ['openFile'] }) - return r.canceled ? null : r.filePaths[0] - }) - ipcMain.handle('select-file', async () => { - const r = await dialog.showOpenDialog({ title: '选择文件', properties: ['openFile'] }) - return r.canceled ? null : r.filePaths[0] - }) - - return win -} - -function waitForProcessAndNavigate(win, targetURL, { intervalMs = 2000, timeoutMs = 999999, exeName = 'iproxy.exe' } = {}, tryNavigate) { - let elapsed = 0 - win.setProgressBar(2, { mode: 'indeterminate' }) - const timer = setInterval(async () => { - const ok = await isProcessRunningWin(exeName) - if (ok) { - clearInterval(timer) - win.setProgressBar(-1) - console.log(`[iproxy] running, go -> ${targetURL}`) - tryNavigate('iproxy-ok') - return - } - elapsed += intervalMs - if (elapsed >= timeoutMs) { - console.warn(`[iproxy] wait timeout ${timeoutMs / 1000}s`) - win.setTitle('YOLO(AI助手ios) - 正在等待 iproxy.exe 启动…(可检查连接/杀软/权限)') - } - }, intervalMs) -} - -module.exports = { createMainWindow, safeLoadURL } diff --git a/package.json b/package.json index 21943e6..f73c288 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "YOLO-ios-ai", "productName": "YOLO(AI助手ios)", - "version": "3.2.0", + "version": "3.6.0", "description": "Vue3 + WS 控制台", "author": "yourname", "main": "main.js",