3.6版本
This commit is contained in:
@@ -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', {
|
||||
|
||||
@@ -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,6 +38,18 @@ function toBuffer(payload) {
|
||||
|
||||
// —— 内部:建立连接(含心跳、keepalive、事件)
|
||||
async function createConnection() {
|
||||
// 1. 如果已有连接,直接返回
|
||||
if (conn) return conn;
|
||||
|
||||
// 2. 如果正在连接中,等待它完成
|
||||
if (connectionLock) {
|
||||
return connectionLock;
|
||||
}
|
||||
|
||||
// 3. 开始新连接,加锁
|
||||
connectionLock = (async () => {
|
||||
try {
|
||||
console.log(`[AMQP] 开始连接 ${CFG.host}...`);
|
||||
const connection = await amqp.connect({
|
||||
protocol: CFG.protocol,
|
||||
hostname: CFG.host,
|
||||
@@ -46,17 +59,22 @@ async function createConnection() {
|
||||
vhost: CFG.vhost,
|
||||
heartbeat: CFG.heartbeat,
|
||||
frameMax: CFG.frameMax > 0 ? CFG.frameMax : undefined,
|
||||
// 也可用 URL 形式:`amqp://u:p@host:5672/vhost?heartbeat=60`
|
||||
});
|
||||
|
||||
// 打开 TCP keepalive,降低 NAT/空闲超时断开的概率
|
||||
// 如果在连接过程中被要求关闭(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); // 15s
|
||||
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);
|
||||
@@ -67,13 +85,12 @@ async function createConnection() {
|
||||
});
|
||||
|
||||
connection.on('close', () => {
|
||||
if (closing) return; // 正在关闭时不重连
|
||||
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);
|
||||
@@ -84,7 +101,17 @@ async function createConnection() {
|
||||
});
|
||||
|
||||
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; // 🔓 解锁
|
||||
}
|
||||
})();
|
||||
|
||||
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'
|
||||
|
||||
313
main.js
313
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,
|
||||
function getQueueNameByType(type, tenantId) {
|
||||
if (type === 'crawler') return `q.tenant.${tenantId}`;
|
||||
if (type === 'boss') return `b.tenant.${tenantId}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
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++; // 所有队列共用计数器
|
||||
const payload = msg.json ?? msg.text;
|
||||
consumeCount++;
|
||||
|
||||
// 标记来源类型:普通队列 / burst 队列
|
||||
const isBurstQueue = qName.startsWith('b.');
|
||||
const isBurstQueue = queueName.startsWith('b.');
|
||||
const meta = isBurstQueue ? 2 : 1;
|
||||
|
||||
console.log(
|
||||
`[MQ消费] [${qName}]`,
|
||||
`[MQ] [${queueName}]`,
|
||||
payload?.hostsId,
|
||||
payload?.country,
|
||||
'共' + consumeCount + '条数据'
|
||||
'count=',
|
||||
consumeCount
|
||||
);
|
||||
|
||||
// ⚠️ 关键:在原有 payload 的基础上,增加 _mqMeta 字段
|
||||
// 这样你之前前端用 payload.hostsId / payload.country 的地方完全不用改
|
||||
const wrapped = {
|
||||
...payload,
|
||||
_mqMeta: meta
|
||||
};
|
||||
|
||||
// 广播到前端
|
||||
emitMessage(wrapped);
|
||||
// 成功返回会在 mq 客户端内部自动 ack
|
||||
},
|
||||
{ prefetch: 1, requeueOnError: false, durable: true, assertQueue: true }
|
||||
);
|
||||
mqConsumers.set(queueName, consumer);
|
||||
}
|
||||
|
||||
// 供渲染进程发送消息到队列(保持原来的 q.tenant.* 不变)
|
||||
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;
|
||||
|
||||
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('<h3>正在等待 iproxy.exe 启动…</h3>'));
|
||||
}
|
||||
// ✅ 不再自动检测 iproxy,3 秒后直接进入业务页面
|
||||
// 🆕 先在等待页面做虚拟机检测:如果是虚拟机 → 弹窗 + 退出
|
||||
(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 }, {
|
||||
await axios.post(
|
||||
'https://crawlclient.api.yolozs.com/api/user/aiChat-logout',
|
||||
{ userId: userData.userId, tenantId: userData.tenantId },
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json', // 设置请求头
|
||||
'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 {
|
||||
|
||||
@@ -897,3 +1025,124 @@ function normalizePath(targetPath, baseDir) {
|
||||
}
|
||||
return path.resolve(targetPath)
|
||||
}
|
||||
|
||||
|
||||
// ======== 虚拟机检测相关工具函数(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<boolean>
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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,<h3>正在等待 iproxy.exe 启动…</h3>'))
|
||||
} else {
|
||||
win.loadURL('data:text/html,<h3>正在等待 iproxy.exe 启动…</h3>')
|
||||
}
|
||||
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 }
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user