稳定测试版
This commit is contained in:
61
main/index.js
Normal file
61
main/index.js
Normal 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
24
main/ipc/files.js
Normal 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
30
main/ipc/mq.js
Normal 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
17
main/ipc/system.js
Normal 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
30
main/preload.js
Normal 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
78
main/services/iosai.js
Normal 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
20
main/services/sse.js
Normal 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
69
main/updater.js
Normal 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
11
main/utils/guard.js
Normal 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
25
main/utils/mem.js
Normal 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
21
main/utils/paths.js
Normal 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
96
main/window.js
Normal 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: `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 }
|
||||
Reference in New Issue
Block a user