899 lines
33 KiB
JavaScript
899 lines
33 KiB
JavaScript
const { app, globalShortcut, BrowserWindow, net, dialog, ipcMain } = require('electron');
|
||
const { startSSE } = require('./js/sse-server');
|
||
const { createBurstBroadcaster } = require('./js/burst-broadcast');
|
||
const mq = require('./js/rabbitmq-service');
|
||
const axios = require('axios');
|
||
const os = require('os');
|
||
const fsp = require('node:fs/promises')
|
||
const { fileURLToPath } = require('node:url')
|
||
|
||
|
||
const path = require('path');
|
||
const USER_ROOT = path.resolve(os.homedir());
|
||
const { autoUpdater } = require('electron-updater')
|
||
const fs = require('fs')
|
||
const log = require('electron-log')
|
||
Object.assign(console, log.functions)
|
||
// 存储 SSE 服务器的引用
|
||
let sseServer = null;
|
||
|
||
|
||
// ✅ 本地 HTTPS 白名单(按你的实际地址改端口)
|
||
const LOCAL_HTTPS_WHITELIST = [
|
||
'https://192.168.1.218:34567',
|
||
'https://192.168.1.231:34567',
|
||
'https://127.0.0.1:34567',
|
||
'https://localhost:34567'
|
||
]
|
||
|
||
let userData = { tenantId: null, userId: null }
|
||
const { exec, spawn, execFile } = require('child_process'); // 如果你用 exec 启动 scrcpy,保留此行
|
||
|
||
// app.commandLine.appendSwitch('remote-debugging-port', '9222'); //远程控制台端口F12
|
||
|
||
app.commandLine.appendSwitch('enable-precise-memory-info')
|
||
app.commandLine.appendSwitch('js-flags', '--expose-gc --max-old-space-size=4096') // 以 MB 为单位,建议设置 2048-4096
|
||
|
||
app.commandLine.appendSwitch('enable-experimental-web-platform-features');
|
||
app.commandLine.appendSwitch('enable-features', 'WebCodecs,MediaStreamInsertableStreams');
|
||
// === 更新门闩:更新进行时,禁止跳转业务页面 ===
|
||
let updateInProgress = false; // 有更新或正在下载 -> true
|
||
let updateDownloaded = false; // 已下载完成(通常会随即重启安装)
|
||
let pendingNavs = []; // 被延迟的跳转请求(函数队列)
|
||
let installingNow = false; // 正在静默安装(用户点击“立即重启安装”之后)
|
||
|
||
|
||
// --- 持久化兼容模式开关(仅影响本机) ---
|
||
const compatFile = path.join(app.getPath('userData'), 'compat.json');
|
||
function readCompat() { try { return JSON.parse(fs.readFileSync(compatFile, 'utf8')); } catch { return {}; } }
|
||
function writeCompat(obj) { try { fs.writeFileSync(compatFile, JSON.stringify(obj, null, 2)); } catch { } }
|
||
|
||
// 支持三种触发:环境变量、命令行、持久化标记
|
||
const SAFE_MODE =
|
||
process.env.YOLO_SAFE_MODE === '1' ||
|
||
process.argv.includes('--safe-mode') ||
|
||
readCompat().safeMode === true;
|
||
|
||
// (可选)允许命令行清除持久化标记:--clear-safe-mode
|
||
if (process.argv.includes('--clear-safe-mode')) {
|
||
const c = readCompat(); delete c.safeMode; writeCompat(c);
|
||
console.log('[兼容性] 已清除安全模式标志');
|
||
}
|
||
|
||
// === 兼容模式等价于 bat 里的那些开关(不含日志) ===
|
||
if (SAFE_MODE) {
|
||
app.disableHardwareAcceleration(); // --disable-gpu
|
||
app.commandLine.appendSwitch('disable-gpu');
|
||
app.commandLine.appendSwitch('in-process-gpu'); // --in-process-gpu
|
||
app.commandLine.appendSwitch('disable-direct-composition'); // --disable-direct-composition
|
||
// 如需更稳再关沙箱:默认开着更安全;若你确实要关,则解除注释
|
||
// app.commandLine.appendSwitch('no-sandbox'); // --no-sandbox
|
||
console.log('[安全模式] 已启用');
|
||
}
|
||
|
||
|
||
|
||
|
||
|
||
function flushPendingNavs() {
|
||
const fns = pendingNavs.slice();
|
||
pendingNavs = [];
|
||
for (const fn of fns) { try { fn(); } catch (e) { console.warn('[导航] 延迟运行错误:', e); } }
|
||
}
|
||
|
||
function safeLoadURL(win, url, onFail) {
|
||
if (!win || win.isDestroyed()) return;
|
||
win.loadURL(url).catch(err => {
|
||
console.warn('[加载url 失败]', err);
|
||
onFail?.(err);
|
||
});
|
||
}
|
||
// 进程异常处理
|
||
process.on('uncaughtException', (error) => {
|
||
console.error('uncaughtException未捕获异常:', error);
|
||
});
|
||
process.on('unhandledRejection', (reason, p) => {
|
||
console.error('[unhandledRejection未捕获异常]', reason);
|
||
});
|
||
|
||
// 渲染崩溃 → 持久化 safeMode=true(下次自动以兼容模式启动)
|
||
app.on('web-contents-created', (_, contents) => {
|
||
contents.on('render-process-gone', (_event, details) => {
|
||
console.error('⚠️ 渲染崩溃:', details);
|
||
if (['launch-failed', 'abnorm al-exit'].includes(details.reason)) {
|
||
const c = readCompat(); c.safeMode = true; writeCompat(c);
|
||
console.warn('[兼容性] 已设置安全模式为true(已持久化),下次启动时将自动使用安全模式');
|
||
|
||
}
|
||
});
|
||
});
|
||
//(可选)更广义的子进程异常也触发降级
|
||
app.on('child-process-gone', (_e, details) => {
|
||
if ((details.type === 'GPU' || details.type === 'Renderer') &&
|
||
['launch-failed', 'abnormal-exit'].includes(details.reason)) {
|
||
const c = readCompat(); c.safeMode = true; writeCompat(c);
|
||
console.warn('[兼容性] 已设置安全模式为true(已持久化),下次启动时将自动使用安全模式');
|
||
|
||
}
|
||
});
|
||
//本地https 证书跳过校验
|
||
app.on('certificate-error', (event, webContents, url, error, certificate, callback) => {
|
||
// console.warn('[certificate-error]', url, error)
|
||
|
||
// 只放行你自己的本地 HTTPS
|
||
if (LOCAL_HTTPS_WHITELIST.some(prefix => url.startsWith(prefix))) {
|
||
event.preventDefault() // 告诉 Electron:我来处理这个错误
|
||
callback(true) // ✅ 忽略证书错误,照样加载
|
||
} else {
|
||
// 其他地址还是正常校验证书,防止误放行
|
||
callback(false)
|
||
}
|
||
})
|
||
|
||
|
||
app.on('ready', () => {
|
||
console.log('[App Ready] 当前版本:', app.getVersion());
|
||
|
||
// 配置日志记录(可选)
|
||
const log = require('electron-log');
|
||
autoUpdater.logger = log;
|
||
autoUpdater.logger.transports.file.level = 'info';
|
||
|
||
// 启用静默更新(关键设置)
|
||
autoUpdater.autoDownload = true; // 自动下载更新
|
||
autoUpdater.autoInstallOnAppQuit = false; // 我们手动调用安装(静默)
|
||
// 监听事件(用于控制流程或调试,可保留日志)
|
||
autoUpdater.on('checking-for-update', () => {
|
||
console.log('[autoUpdater] 正在检查更新...');
|
||
});
|
||
|
||
autoUpdater.on('update-available', async (info) => {
|
||
updateInProgress = true; // ⛔ 开始阻塞导航
|
||
console.log(`[autoUpdater] 检测到新版本: ${info.version},开始自动下载...`);
|
||
await killIOSAI()
|
||
broadcast('update:available', info);
|
||
});
|
||
let lastSend = 0;
|
||
autoUpdater.on('update-not-available', () => {
|
||
console.log('[autoUpdater] 当前已是最新版本');
|
||
updateInProgress = false; // ✅ 解锁
|
||
flushPendingNavs(); // 立刻执行被延迟的导航
|
||
});
|
||
|
||
autoUpdater.on('download-progress', (p) => {
|
||
updateInProgress = true; // ⛔ 持续阻塞
|
||
// 节流 150ms,避免刷屏
|
||
const now = Date.now();
|
||
if (now - lastSend > 150) {
|
||
lastSend = now;
|
||
broadcast('update:progress', {
|
||
percent: p.percent, // 0~100
|
||
bytesPerSecond: p.bytesPerSecond, // B/s
|
||
transferred: p.transferred, // 已下载字节
|
||
total: p.total // 总字节
|
||
});
|
||
}
|
||
|
||
// 维持你原来的任务栏进度
|
||
const win = BrowserWindow.getAllWindows()[0];
|
||
if (win) win.setProgressBar(p.percent / 100);
|
||
});
|
||
|
||
autoUpdater.on('update-downloaded', (info) => {
|
||
updateInProgress = true; // 已下载,通常马上安装并退出
|
||
updateDownloaded = true;
|
||
console.log('[autoUpdater] 更新下载完成');
|
||
broadcast('update:downloaded', info);
|
||
|
||
// 如果想“用户点按钮再安装”,注释掉下一行,交给渲染进程触发
|
||
// autoUpdater.quitAndInstall(false, true);
|
||
});
|
||
|
||
autoUpdater.on('error', (err) => {
|
||
console.error('[autoUpdater] 错误:', err);
|
||
broadcast('update:error', { message: String(err) });
|
||
updateInProgress = false; // ✅ 出错也解锁(按需可保持阻塞)
|
||
flushPendingNavs();
|
||
});
|
||
// 可选:提供渲染端调用的安装入口
|
||
|
||
|
||
// ✅ 渲染端主动触发静默安装(不显示 UI)
|
||
ipcMain.handle('update:quitAndInstallNow', async () => {
|
||
try {
|
||
installingNow = true; // 进入“静默安装中”阶段
|
||
// 安装前做一次业务清理:但避免误杀自身或阻塞
|
||
await killIOSAI();
|
||
} catch (e) {
|
||
console.warn('[更新] 预安装清理警告:', e);
|
||
|
||
}
|
||
// electron-updater 实际上传给 NSIS /S 静默参数,第二个 true 表示强制重启后运行新版本
|
||
autoUpdater.quitAndInstall(false, true);
|
||
});
|
||
ipcMain.handle('update:checkNow', () => {
|
||
return autoUpdater.checkForUpdates();
|
||
});
|
||
|
||
// 启动检查更新(不弹窗)
|
||
autoUpdater.checkForUpdates();
|
||
});
|
||
|
||
// 应用程序生命周期管理
|
||
app.whenReady().then(() => {
|
||
setInterval(dumpAllMem, 5000); //循环查看内存情况
|
||
|
||
|
||
// 再创建窗口
|
||
createWindow();
|
||
|
||
// macOS 特有:激活时若无打开窗口再创建
|
||
app.on('activate', () => {
|
||
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
||
});
|
||
|
||
|
||
// globalShortcut.register('F12', () => toggleDevtools(BrowserWindow.getFocusedWindow()));
|
||
globalShortcut.register('Control+Shift+i', () => toggleDevtools(BrowserWindow.getFocusedWindow()));
|
||
// globalShortcut.register('Control+Alt+m', () => toggleDevtools(BrowserWindow.getFocusedWindow()));
|
||
});
|
||
app.on('will-quit', () => globalShortcut.unregisterAll());
|
||
|
||
|
||
// 仅在“不是更新安装中”时,做你的退出清理
|
||
app.on('before-quit', async () => {
|
||
if (!installingNow) {
|
||
await killIOSAI();
|
||
} else {
|
||
console.log('[退出前] 跳过 killIOSAI 因为 installingNow = true');
|
||
|
||
}
|
||
});
|
||
function toggleDevtools(win) {
|
||
if (!win) return;
|
||
const wc = win.webContents;
|
||
if (wc.isDevToolsOpened()) wc.closeDevTools();
|
||
else wc.openDevTools({ mode: 'detach' }); // 独立窗口
|
||
}
|
||
|
||
// 窗口全部关闭时,退出或保留后台进程
|
||
app.on('window-all-closed', async () => {
|
||
await killIOSAI();
|
||
if (process.platform !== 'darwin') {
|
||
app.quit();
|
||
}
|
||
});
|
||
// 再加几道保险(进程异常/信号也关)
|
||
process.on('exit', killIOSAI);
|
||
process.on('SIGINT', killIOSAI);
|
||
process.on('SIGTERM', killIOSAI);
|
||
|
||
function toMB(v) {
|
||
if (!v || v <= 0) return 0;
|
||
// 某些平台字段是 bytes、也有历史 Electron 返回 KB 的情况,做兜底
|
||
// 这里按优先:bytes->MB,其次 KB->MB,最后假定已经是MB
|
||
// const bytesToMB = v / (1024 * 1024);
|
||
// if (bytesToMB > 1) return Number(bytesToMB.toFixed(1)); // 看起来像 bytes
|
||
const kbToMB = v / 1024;
|
||
if (kbToMB > 1) return Number(kbToMB.toFixed(1)); // 看起来像 KB
|
||
return Number(v.toFixed(1)); // 当作 MB
|
||
}
|
||
|
||
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('>Tab workingSetMB=', report[3].workingSetMB);
|
||
|
||
// // 渲染进程类型在不同版本可能是 "Renderer" 或 "Tab"
|
||
// const isRenderer = (t) => t === 'Renderer' || t === 'Tab';
|
||
// const renderers = report.filter(r => isRenderer(r.type));
|
||
// const gpu = report.filter(r => r.type.toLowerCase().includes('gpu'));
|
||
|
||
// const sum = (arr, k) => arr.reduce((a, b) => a + (b[k] || 0), 0).toFixed(1);
|
||
// console.log('> [MEM] SUM Renderer workingSetMB=', sum(renderers, 'workingSetMB'));
|
||
// if (gpu.length) console.log('> [MEM] GPU=', gpu);
|
||
} catch (e) {
|
||
console.warn('获取应用指标错误:', e);
|
||
|
||
}
|
||
}
|
||
|
||
// 在函数外定义计数器(或者放在函数内部,用闭包封装)
|
||
let consumeCount = 0;
|
||
async function setupMQConsumerAndPublisher(emitMessage, tenantId) {
|
||
const queueNames = [
|
||
`q.tenant.${tenantId}`,
|
||
`b.tenant.${tenantId}`, // 新增队列
|
||
];
|
||
|
||
for (const qName of queueNames) {
|
||
await mq.startConsumer(
|
||
qName,
|
||
async (msg) => {
|
||
const payload = msg.json ?? msg.text; // 原始业务数据
|
||
consumeCount++; // 所有队列共用计数器
|
||
|
||
// 标记来源类型:普通队列 / burst 队列
|
||
const isBurstQueue = qName.startsWith('b.');
|
||
const meta = isBurstQueue ? 2 : 1;
|
||
|
||
console.log(
|
||
`[MQ消费] [${qName}]`,
|
||
payload?.hostsId,
|
||
payload?.country,
|
||
'共' + consumeCount + '条数据'
|
||
);
|
||
|
||
// ⚠️ 关键:在原有 payload 的基础上,增加 _mqMeta 字段
|
||
// 这样你之前前端用 payload.hostsId / payload.country 的地方完全不用改
|
||
const wrapped = {
|
||
...payload,
|
||
_mqMeta: meta
|
||
};
|
||
|
||
// 广播到前端
|
||
emitMessage(wrapped);
|
||
// 成功返回会在 mq 客户端内部自动 ack
|
||
},
|
||
{ prefetch: 1, requeueOnError: false, durable: true, assertQueue: true }
|
||
);
|
||
}
|
||
|
||
// 供渲染进程发送消息到队列(保持原来的 q.tenant.* 不变)
|
||
ipcMain.removeHandler('mq-send');
|
||
ipcMain.handle('mq-send', async (_event, user) => {
|
||
console.log('消息已发送', user);
|
||
await mq.publishToQueue(`q.tenant.${tenantId}`, user);
|
||
return { ok: true };
|
||
});
|
||
}
|
||
|
||
|
||
|
||
/**
|
||
* 创建 BrowserWindow,并在加载 http://localhost:8080/ 失败时启动定时检测,成功后自动 reload
|
||
*/
|
||
function createWindow() {
|
||
const appVersion = app.getVersion(); // 获取 package.json 中的 version
|
||
const win = new BrowserWindow({
|
||
width: 1920,
|
||
height: 1080,
|
||
title: `YOLO(AI助手ios) v${appVersion}`, // 设置窗口标题
|
||
frame: true,
|
||
fullscreenable: false,
|
||
webPreferences: {
|
||
preload: path.join(__dirname, 'js', 'preload.js'),
|
||
contextIsolation: true,
|
||
nodeIntegration: false,
|
||
|
||
// 推荐:默认 true;只有 SAFE_MODE 才 false(等价 --no-sandbox)
|
||
sandbox: SAFE_MODE ? false : true,
|
||
|
||
webSecurity: true,
|
||
backgroundThrottling: false,
|
||
enableRemoteModule: false,
|
||
offscreen: false,
|
||
|
||
// 兼容模式更稳:关实验特性
|
||
experimentalFeatures: SAFE_MODE ? false : true,
|
||
|
||
autoplayPolicy: 'no-user-gesture-required',
|
||
devTools: true,
|
||
}
|
||
});
|
||
|
||
win.setMenu(null); // 禁用菜单栏F12
|
||
win.maximize();
|
||
|
||
|
||
// ✅ 把渲染进程的 console.* 打进 main 的日志
|
||
win.webContents.on('console-message', (event, level, message, line, sourceId) => {
|
||
// level: 0=log,1=warn,2=error,3=info,4=debug
|
||
if (level == 1) {
|
||
console.log(
|
||
`[页面日志] ${message}`
|
||
);
|
||
}
|
||
|
||
});
|
||
|
||
|
||
// 自动判断环境使用不同的页面地址
|
||
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';
|
||
console.log('[页面加载] 使用地址:', targetURL);
|
||
|
||
let retryIntervalId = null;
|
||
|
||
// 启动定时重试逻辑
|
||
function scheduleRetry() {
|
||
const intervalMs = 5000; // 每 5 秒检测一次,可根据需求调整
|
||
console.log(`[Auto-Reconnect] 启动定时检测,每 ${intervalMs / 1000}s 重新检测服务`);
|
||
}
|
||
// 统一的“尝试跳转”函数:更新中 -> 延迟;否则直接跳
|
||
const tryNavigate = (reasonTag = '') => {
|
||
const ts = Date.now() // 或者用前端版本号
|
||
const go = () => safeLoadURL(win, `${targetURL}?t=${ts}`, () => {
|
||
if (!retryIntervalId) scheduleRetry();
|
||
});
|
||
|
||
if (updateInProgress) {
|
||
console.log(`[Nav] 阻塞跳转(${reasonTag}):正在更新中,等待解锁`);
|
||
pendingNavs.push(go); // 等待 autoUpdater 事件解锁后 flush 执行
|
||
return;
|
||
}
|
||
go();
|
||
};
|
||
|
||
// 监听前端请求文件选择
|
||
ipcMain.handle('select-apk-file', async () => {
|
||
const result = await dialog.showOpenDialog({
|
||
title: '选择 APK 文件',
|
||
filters: [{ name: 'APK', extensions: ['apk'] }],
|
||
properties: ['openFile']
|
||
})
|
||
|
||
if (result.canceled || result.filePaths.length === 0) {
|
||
return null
|
||
}
|
||
|
||
return result.filePaths[0]
|
||
})
|
||
// 监听前端请求文件选择
|
||
ipcMain.handle('select-file', async () => {
|
||
const result = await dialog.showOpenDialog({
|
||
title: '选择文件',
|
||
// filters: [{ name: 'APK', extensions: ['apk'] }],
|
||
properties: ['openFile']
|
||
})
|
||
|
||
if (result.canceled || result.filePaths.length === 0) {
|
||
return null
|
||
}
|
||
|
||
return result.filePaths[0]
|
||
})
|
||
|
||
// 接收渲染进程请求执行 GC
|
||
ipcMain.handle('manual-gc', () => {
|
||
if (global.gc) {
|
||
global.gc();
|
||
console.log('🧹 手动触发 GC 成功');
|
||
} else {
|
||
console.warn('⚠️ global.gc 不存在,请确认是否添加了 --expose-gc');
|
||
}
|
||
});
|
||
|
||
|
||
// 小工具:异步 sleep
|
||
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
||
|
||
/**
|
||
* 前端调用:一直检测,直到检测到 iproxy.exe 才返回
|
||
* 用法:
|
||
* const res = await window.electron.ipcRenderer.invoke('iproxy:waitUntilRunning', { intervalMs: 2000, exeName: 'iproxy.exe' })
|
||
* if (res.running) { /* 决定是否跳转 *\/ }
|
||
*/
|
||
ipcMain.handle('isiproxy', async (_event, opts = {}) => {
|
||
startIOSAIExecutable(); //启动iosAi服务
|
||
const { intervalMs = 2000, exeName = 'iproxy.exe', maxWaitMs = 0 } = opts;
|
||
|
||
// 非 Windows 平台直接认为通过(按需改)
|
||
if (process.platform !== 'win32') return { running: true, platform: process.platform };
|
||
|
||
const start = Date.now();
|
||
while (true) {
|
||
const ok = await isProcessRunningWin(exeName);
|
||
if (ok) return { running: true };
|
||
if (maxWaitMs > 0 && (Date.now() - start) >= maxWaitMs) {
|
||
return { running: false, timeout: true };
|
||
}
|
||
await sleep(intervalMs);
|
||
}
|
||
});
|
||
|
||
|
||
|
||
// 仅检查固定路径:C:\Users\Administrator\IOSAI\aiConfig.json
|
||
ipcMain.handle('file-exists', async () => {
|
||
try {
|
||
// ★ 固定路径(如需改用户,改这里即可)
|
||
const fixedPath = `${USER_ROOT}\\IOSAI\\aiConfig.json`;
|
||
|
||
// 用 stat 判断是否存在 & 获取信息
|
||
const stat = await fsp.stat(fixedPath).catch(err => {
|
||
if (err && err.code === 'ENOENT') return null; // 不存在
|
||
throw err; // 其他错误抛出
|
||
});
|
||
|
||
if (!stat) {
|
||
return { ok: true, exists: false, path: fixedPath };
|
||
}
|
||
|
||
return {
|
||
ok: true,
|
||
exists: true,
|
||
path: fixedPath,
|
||
isFile: stat.isFile(),
|
||
isDirectory: stat.isDirectory(),
|
||
size: stat.size,
|
||
mtimeMs: stat.mtimeMs
|
||
};
|
||
} catch (e) {
|
||
return { ok: false, error: e.message || String(e) };
|
||
}
|
||
});
|
||
|
||
|
||
|
||
|
||
// // 判断文件存不存在
|
||
// ipcMain.handle('file-exists', async (_evt, targetPath, baseDir /* 可选 */) => {
|
||
// try {
|
||
// const full = normalizePath(targetPath, baseDir)
|
||
// const stat = await fsp.stat(full).catch(err => {
|
||
// if (err && err.code === 'ENOENT') return null
|
||
// throw 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) }
|
||
// }
|
||
// })
|
||
|
||
// 监听加载失败事件
|
||
win.webContents.on('did-fail-load', (event, errorCode, errorDescription, validatedURL, isMainFrame) => {
|
||
if (isMainFrame) {
|
||
console.warn(`[did-fail-load] 加载失败(${errorCode}): ${errorDescription},URL: ${validatedURL}`);
|
||
if (!retryIntervalId) {
|
||
scheduleRetry();
|
||
}
|
||
}
|
||
});
|
||
|
||
|
||
|
||
|
||
// 监听加载完成事件:一旦成功加载,清除重试定时器
|
||
win.webContents.on('did-finish-load', () => {
|
||
const currentURL = win.webContents.getURL();
|
||
console.log(`[did-finish-load] 加载完成,当前 URL: ${currentURL}`)
|
||
|
||
if (retryIntervalId) {
|
||
clearInterval(retryIntervalId);
|
||
retryIntervalId = null;
|
||
}
|
||
|
||
});
|
||
|
||
// === 首次加载:先上“加载页” ===
|
||
const loadingFile = path.join(__dirname, 'waiting.html');
|
||
const hasLoading = fs.existsSync(loadingFile);
|
||
|
||
if (process.platform === 'win32') {
|
||
if (hasLoading) {
|
||
win.loadFile(loadingFile).catch(err => {
|
||
console.warn('[加载页 loadFile 失败,退回内联HTML]', err);
|
||
// 兜底:内联一个极简页面
|
||
win.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent('<h3>正在等待 iproxy.exe 启动…</h3>'));
|
||
});
|
||
} else {
|
||
// 如果没有把加载页文件放到指定位置,给个兜底
|
||
win.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent('<h3>正在等待 iproxy.exe 启动…</h3>'));
|
||
}
|
||
// ✅ 不再自动检测 iproxy,3 秒后直接进入业务页面
|
||
setTimeout(() => {
|
||
tryNavigate('delay-3s-autogo');
|
||
}, 3000);
|
||
// // 开始检测 iproxy,检测到后再跳转 targetURL
|
||
// waitForIproxyAndNavigate(win, targetURL, {
|
||
// intervalMs: 2000,
|
||
// timeoutMs: 999999,
|
||
// exeName: 'iproxy.exe'
|
||
// }, tryNavigate);
|
||
} else {
|
||
// 非 Windows 平台直接进页面
|
||
tryNavigate('non-win-first');
|
||
}
|
||
|
||
|
||
//启动sse
|
||
sseServer = startSSE();
|
||
const emitMessage = createBurstBroadcaster(sseServer.broadcast, {
|
||
event: 'message', // 你前端监 n听的事件名
|
||
idleMs: 10_000,
|
||
startPayload: 'start',
|
||
startOnFirst: true // 如果不想程序启动后的第一条先发 start,就设为 false
|
||
});
|
||
|
||
// 开启mq
|
||
ipcMain.handle('start-mq', async (event, tentId, id) => {
|
||
userData.tenantId = tentId;
|
||
userData.userId = id;
|
||
//启动mq
|
||
setupMQConsumerAndPublisher(emitMessage, tentId)
|
||
|
||
});
|
||
// 可选:开发阶段打开 DevTools F12
|
||
// win.webContents.openDevTools();
|
||
|
||
return win;
|
||
}
|
||
|
||
|
||
|
||
// —— 启动 iOSAI.exe ——
|
||
// 兼容开发/打包路径:优先同级目录,其次 resources,再到源码目录
|
||
function resolveIOSAIPath() {
|
||
const candidates = [
|
||
// 打包后:和应用同级目录(app.exe 同目录)
|
||
path.join(path.dirname(process.execPath), 'iOSAI', 'IOSAI.exe'),
|
||
// 打包后:在 resources 目录(需要用 electron-builder 的 extraResources 拷贝进去)
|
||
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;
|
||
}
|
||
|
||
let iosAIProcess = null; // 如果你后面想控制/关闭它,用得到;“只要打开就行”也可以不管
|
||
|
||
// function startIOSAIExecutable() {
|
||
// if (process.platform !== 'win32') {
|
||
// console.warn('[IOSAI] 当前不是 Windows,跳过启动 IOSAI.exe');
|
||
// return;
|
||
// }
|
||
// const exePath = resolveIOSAIPath();
|
||
// if (!exePath) {
|
||
// console.error('[IOSAI] 未找到 IOSAI.exe,请确认路径:根目录/iOSAI/IOSAI.exe(或已打包到 resources)');
|
||
// return;
|
||
// }
|
||
|
||
// const exeDir = path.dirname(exePath);
|
||
// const exeFile = path.basename(exePath);
|
||
|
||
// // 打印确认:应为 ...\IOSAI.exe
|
||
// console.log('[IOSAI] 计划启动文件:', exePath);
|
||
// console.log('[IOSAI] 文件存在:', fs.existsSync(exePath));
|
||
|
||
// // 注意:start 的第一个带引号参数是窗口标题,这里用 "" 占位
|
||
// // /D 指定工作目录;最后只传文件名,避免整条路径再被拆分
|
||
// // const cmd = `start "" /D "${exeDir}" "${exeFile}"`;
|
||
// const params = 'iosai';
|
||
|
||
// const cmd = `start "" /D "${exeDir}" "${exeFile}" ${params}`;
|
||
// try {
|
||
// exec(cmd, { cwd: exeDir, windowsHide: false }, (err) => {
|
||
// if (err) {
|
||
// console.error('[IOSAI] 启动失败:', err);
|
||
// } else {
|
||
// console.log('[IOSAI] 启动命令已执行(独立控制台窗口中运行)');
|
||
// }
|
||
// });
|
||
// } catch (e) {
|
||
// console.error('[IOSAI] 启动异常:', e);
|
||
// }
|
||
// }
|
||
|
||
// 启动 IOSAI.exe(管理员权限)
|
||
// 检查当前 Electron 是否已是管理员(高完整性)
|
||
function isElevated(cb) {
|
||
// 查询当前进程完整性级别:High 表示管理员
|
||
exec('whoami /groups', { windowsHide: true }, (err, stdout) => {
|
||
if (err) return cb(false);
|
||
cb(/High Mandatory Level/i.test(stdout));
|
||
});
|
||
}
|
||
|
||
function psEscapeSingleQuotes(s) {
|
||
return s.replace(/'/g, "''");
|
||
}
|
||
|
||
function startIOSAIExecutable() {
|
||
if (process.platform !== 'win32') {
|
||
console.warn('[IOSAI] 当前不是 Windows,跳过启动 IOSAI.exe');
|
||
return;
|
||
}
|
||
|
||
const exePath = resolveIOSAIPath();
|
||
if (!exePath || !fs.existsSync(exePath)) {
|
||
console.error('[IOSAI] 未找到 IOSAI.exe:', exePath);
|
||
return;
|
||
}
|
||
const exeDir = path.dirname(exePath);
|
||
const args = ['iosai'];
|
||
|
||
isElevated((elev) => {
|
||
console.log('[IOSAI] 当前 Electron 是否管理员:', elev);
|
||
|
||
if (elev) {
|
||
const exeFile = path.basename(exePath);
|
||
|
||
// 已经是管理员:直接普通方式启动即可
|
||
const cmd = `start "" /D "${exeDir}" "${exeFile}" ${args[0]}`;
|
||
exec(cmd, { cwd: exeDir, windowsHide: false }, (err) => {
|
||
console.log(err)
|
||
if (err) console.error('[IOSAI] 启动失败:', err);
|
||
else console.log('[IOSAI] 已启动(父进程已是管理员,无需 UAC 弹窗)');
|
||
});
|
||
return;
|
||
}
|
||
|
||
// 未提权 → 用 PowerShell 提权
|
||
const exePathPS = psEscapeSingleQuotes(exePath);
|
||
const exeDirPS = psEscapeSingleQuotes(exeDir);
|
||
const argListPS = `@(${args.map(a => `'${psEscapeSingleQuotes(String(a))}'`).join(',')})`;
|
||
|
||
const psCommand = `Start-Process -FilePath '${exePathPS}' -ArgumentList ${argListPS} -WorkingDirectory '${exeDirPS}' -Verb RunAs -WindowStyle Normal -PassThru`;
|
||
console.log('[IOSAI] 提权启动命令(PS):', psCommand);
|
||
|
||
execFile(
|
||
'powershell.exe',
|
||
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', psCommand],
|
||
{ windowsHide: true },
|
||
(err) => {
|
||
if (err) {
|
||
console.error('[IOSAI] 提权启动失败:', err);
|
||
} else {
|
||
console.log('[IOSAI] 已请求管理员启动(按系统策略可能不弹 UAC)');
|
||
// 可选:用 tasklist 简单确认是否起来了
|
||
setTimeout(() => {
|
||
exec('tasklist /FI "IMAGENAME eq IOSAI.exe"', { windowsHide: true }, (e2, out) => {
|
||
console.log('[IOSAI] 进程检查:\n', out);
|
||
});
|
||
}, 1200);
|
||
}
|
||
}
|
||
);
|
||
});
|
||
}
|
||
|
||
|
||
async function killIOSAI() {
|
||
console.log('尝试关闭IOSAI', userData);
|
||
// console.log('axios', axios);
|
||
|
||
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
|
||
}
|
||
}).then(response => {
|
||
console.log("发送登出请求成功")
|
||
}).catch(error => {
|
||
console.log("发送登出请求错误", error)
|
||
}).finally(error => {
|
||
// console.log("发送")
|
||
})
|
||
try {
|
||
|
||
if (iosAIProcess && iosAIProcess.pid) {
|
||
// 按 PID 杀,并带上 /T 杀掉它可能拉起的子进程
|
||
exec(`taskkill /PID ${iosAIProcess.pid} /T /F`, (err) => {
|
||
if (err) {
|
||
console.warn('[IOSAI] 按 PID 关闭失败,改用按进程名:', err.message);
|
||
// 兜底:按进程名杀(会杀掉系统里所有 IOSAI.exe,若允许同名多实例请注意)
|
||
exec('taskkill /IM IOSAI.exe /F');
|
||
}
|
||
});
|
||
} else {
|
||
// 没有 PID(比如程序二次拉起后原 PID 退出),直接按进程名兜底
|
||
exec('taskkill /IM IOSAI.exe /F');
|
||
exec('taskkill /IM iproxy.exe /F');
|
||
exec('taskkill /IM pythonw.exe /F');
|
||
exec('taskkill /IM ios.exe /F');
|
||
}
|
||
} catch (_) { }
|
||
iosAIProcess = null;
|
||
}
|
||
|
||
const broadcast = (channel, payload) => {
|
||
BrowserWindow.getAllWindows().forEach(w => {
|
||
if (!w.isDestroyed()) w.webContents.send(channel, payload);
|
||
});
|
||
};
|
||
|
||
|
||
// —— 进程检测:Windows 使用 tasklist 精确匹配 ——
|
||
// 返回 Promise<boolean>
|
||
function isProcessRunningWin(exeName) {
|
||
return new Promise((resolve) => {
|
||
if (process.platform !== 'win32') return resolve(true); // 非 Win 平台直接放行
|
||
// /FI 过滤进程名;/FO CSV 便于稳定解析;/NH 去表头
|
||
const cmd = `tasklist /FI "IMAGENAME eq ${exeName}" /FO CSV /NH`;
|
||
exec(cmd, { windowsHide: true }, (err, stdout) => {
|
||
if (err || !stdout) return resolve(false);
|
||
// 匹配形如:"iproxy.exe","xxxx","Console",...
|
||
const found = stdout.toLowerCase().includes(`"${exeName.toLowerCase()}"`);
|
||
resolve(found);
|
||
});
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 等待 iproxy.exe 启动后跳转到目标 URL
|
||
* @param {BrowserWindow} win
|
||
* @param {string} targetURL
|
||
* @param {object} options
|
||
*/
|
||
function waitForIproxyAndNavigate(win, targetURL, options = {}, tryNavigate) {
|
||
const {
|
||
intervalMs = 2000, // 轮询间隔
|
||
timeoutMs = 999999, // 超时提示(不强制跳转)
|
||
exeName = 'iproxy.exe'
|
||
} = options;
|
||
|
||
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] 检测到 ${exeName} 已运行,进入页面:${targetURL}`);
|
||
// 检测到后再真正加载业务页面
|
||
tryNavigate('no-waiting-file')
|
||
return;
|
||
}
|
||
elapsed += intervalMs;
|
||
if (elapsed >= timeoutMs) {
|
||
// 仅提示,不强制退出。你也可以在此弹窗或修改窗口标题
|
||
console.warn(`[iproxy] 等待 ${exeName} 超时 ${timeoutMs / 1000}s,仍未检测到进程。`);
|
||
win.setTitle('YOLO(AI助手ios) - 正在等待 iproxy.exe 启动…(可检查连接/杀软/权限)');
|
||
// 到时间后依旧保持加载页 + 继续轮询(如果想停止轮询,把 clearInterval 放这里)
|
||
// clearInterval(timer);
|
||
}
|
||
}, intervalMs);
|
||
}
|
||
|
||
|
||
/**
|
||
* 将传入路径标准化:
|
||
* - 支持 file:// URL
|
||
* - 相对路径会按 baseDir 解析(不传 baseDir 则按当前进程工作目录)
|
||
*/
|
||
function normalizePath(targetPath, baseDir) {
|
||
if (typeof targetPath !== 'string' || !targetPath.trim()) {
|
||
throw new Error('无效的路径参数')
|
||
}
|
||
// 支持 file://
|
||
if (targetPath.startsWith('file://')) {
|
||
return fileURLToPath(new URL(targetPath))
|
||
}
|
||
// 相对路径 → 解析到 baseDir
|
||
if (!path.isAbsolute(targetPath)) {
|
||
const base = baseDir && typeof baseDir === 'string' ? baseDir : process.cwd()
|
||
return path.resolve(base, targetPath)
|
||
}
|
||
return path.resolve(targetPath)
|
||
} |