稳定测试版

This commit is contained in:
2025-10-28 19:40:13 +08:00
commit 183feef2ea
24 changed files with 11631 additions and 0 deletions

61
main/index.js Normal file
View File

@@ -0,0 +1,61 @@
// 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 })
})

24
main/ipc/files.js Normal file
View File

@@ -0,0 +1,24 @@
// 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 }

30
main/ipc/mq.js Normal file
View File

@@ -0,0 +1,30 @@
// 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 }

17
main/ipc/system.js Normal file
View File

@@ -0,0 +1,17 @@
// 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 }

30
main/preload.js Normal file
View File

@@ -0,0 +1,30 @@
// 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');
});

78
main/services/iosai.js Normal file
View File

@@ -0,0 +1,78 @@
// 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 }

20
main/services/sse.js Normal file
View File

@@ -0,0 +1,20 @@
// 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 }

69
main/updater.js Normal file
View File

@@ -0,0 +1,69 @@
// 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 }

11
main/utils/guard.js Normal file
View File

@@ -0,0 +1,11 @@
// 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 }

25
main/utils/mem.js Normal file
View File

@@ -0,0 +1,25 @@
// 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 }

21
main/utils/paths.js Normal file
View File

@@ -0,0 +1,21 @@
// 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 }

96
main/window.js Normal file
View File

@@ -0,0 +1,96 @@
// 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: `YOLOAI助手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('YOLOAI助手ios - 正在等待 iproxy.exe 启动…(可检查连接/杀软/权限)')
}
}, intervalMs)
}
module.exports = { createMainWindow, safeLoadURL }