const { app, BrowserWindow, ipcMain } = require('electron') const path = require('path') const os = require('os') const fs = require('fs') const unpackedRoot = app.isPackaged ? path.join(path.dirname(process.execPath), 'resources', 'app.asar.unpacked') : path.join(__dirname, '..') // static 不打包,放在打包根目录(与 exe 同级)作为沙盒目录 const sandboxRoot = app.isPackaged ? path.dirname(process.execPath) : path.join(__dirname, '..') const staticDir = path.resolve(sandboxRoot, 'static') if (!fs.existsSync(staticDir)) { fs.mkdirSync(staticDir, { recursive: true }) } // 打包后优先从 exe 同目录读 config.js(python/node 等路径以 exe 目录为根),否则从 app.asar.unpacked 读 const configPath = path.join(unpackedRoot, 'config.js') const configPathFallback = path.join(sandboxRoot, 'config.js') let config try { if (app.isPackaged && fs.existsSync(configPathFallback)) { config = require(configPathFallback) } else { config = require(configPath) } } catch (e) { try { config = require(configPathFallback) } catch (e2) { config = require(configPath) } } const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged const { getPythonExeFromConfig } = require('../nodejs/python-exe-from-config.js') // Node 可执行文件路径:统一从根目录 config.js 的 nodejsPath 读取(源码与打包一致) function getNodeExecutable() { const base = app.isPackaged ? sandboxRoot : path.join(__dirname, '..') const rel = config.nodejsPath || 'node' const full = path.isAbsolute(rel) ? rel : path.join(base, rel) return fs.existsSync(full) ? full : (app.isPackaged ? full : 'node') } function getNodeEnv() { return { ...process.env, STATIC_ROOT: staticDir } } const nodeExecutable = getNodeExecutable() // 打包后:userData 放在 exe 同目录 UserData,实现「开包即用」— 整包复制到任意电脑即可使用 // 开发时:使用临时目录,避免污染项目 if (process.platform === 'win32') { const userDataPath = app.isPackaged ? path.join(sandboxRoot, 'UserData') : path.join(os.tmpdir(), 'AndroidRemoteController') if (!fs.existsSync(userDataPath)) { fs.mkdirSync(userDataPath, { recursive: true }) } const cacheDir = path.join(userDataPath, 'Cache') const gpuCacheDir = path.join(userDataPath, 'GPUCache') if (!fs.existsSync(cacheDir)) fs.mkdirSync(cacheDir, { recursive: true }) if (!fs.existsSync(gpuCacheDir)) fs.mkdirSync(gpuCacheDir, { recursive: true }) app.setPath('userData', userDataPath) app.commandLine.appendSwitch('disk-cache-dir', cacheDir) app.commandLine.appendSwitch('gpu-disk-cache-dir', gpuCacheDir) app.commandLine.appendSwitch('disable-gpu-shader-disk-cache') } // 保存主窗口引用,用于推送消息 let mainWindowInstance = null // 启动日志:写入 userData/startup.log,便于排查“无 UI”问题 const startupLogPath = path.join(app.getPath('userData'), 'startup.log') function startupLog (msg) { const line = `[${new Date().toISOString()}] ${msg}\n` try { fs.appendFileSync(startupLogPath, line) } catch (e) {} console.log(msg) } function createWindow() { startupLog(`createWindow: isPackaged=${app.isPackaged} isDev=${isDev} unpackedRoot=${unpackedRoot}`) startupLog(`staticDir=${staticDir} exists=${fs.existsSync(staticDir)}`) const mainWindow = new BrowserWindow({ width: config.window.width, height: config.window.height, autoHideMenuBar: config.window.autoHideMenuBar, // 从配置文件读取 webPreferences: { nodeIntegration: false, contextIsolation: true, preload: path.join(__dirname, 'preload.js') } }) // 保存窗口引用 mainWindowInstance = mainWindow // 关闭窗口时先结束 adb,便于删除打包目录 mainWindow.on('close', () => { killAdbOnExit() }) if (isDev) { const vitePort = config.vite?.port || 5173 const viteHost = config.vite?.host || 'localhost' startupLog(`Loading Vite dev server at http://${viteHost}:${vitePort}`) // Clear cache before dev load to avoid ERR_CACHE_READ_FAILURE with react-refresh mainWindow.webContents.session.clearCache().then(() => { mainWindow.loadURL(`http://${viteHost}:${vitePort}`) }) // 根据配置文件决定是否打开调试侧边栏 if (config.devTools.enabled) { mainWindow.webContents.openDevTools() } } else { // 前端资源在 exe 同级 dist/ 下(打包时从 package/build/web 复制) const indexPath = path.join(sandboxRoot, 'dist', 'index.html') const indexExists = fs.existsSync(indexPath) startupLog(`Packaged load: index.html path=${indexPath} exists=${indexExists}`) if (!indexExists) { startupLog('ERROR: dist/index.html 不存在,请先 npm run build 再打包') } mainWindow.webContents.on('did-fail-load', (event, errorCode, errorDescription, validatedURL, isMainFrame) => { startupLog(`did-fail-load: mainFrame=${isMainFrame} code=${errorCode} desc=${errorDescription} url=${validatedURL}`) }) mainWindow.webContents.on('did-finish-load', () => { startupLog(`did-finish-load: url=${mainWindow.webContents.getURL()}`) }) mainWindow.loadFile(indexPath) } startupLog(`启动日志文件: ${startupLogPath}`) } const { spawn, spawnSync, execSync } = require('child_process') // 存储运行中的进程 const runningProcesses = new Map() // Execute Node.js script(json-parser、directory-parser 在主进程内执行,不 spawn node.exe) const IN_PROCESS_SCRIPTS = ['json-parser', 'directory-parser'] ipcMain.handle('run-nodejs-script', async (event, scriptName, ...parameters) => { if (IN_PROCESS_SCRIPTS.includes(scriptName)) { const scriptPath = path.resolve(unpackedRoot, 'nodejs', scriptName + '.js') const altPath = app.isPackaged ? path.resolve(sandboxRoot, 'resources', 'app.asar.unpacked', 'nodejs', scriptName + '.js') : null const resolvedPath = fs.existsSync(scriptPath) ? scriptPath : (altPath && fs.existsSync(altPath) ? altPath : null) if (!resolvedPath) { const tried = scriptPath + (altPath ? ' 或 ' + altPath : '') return { success: false, stdout: '', stderr: 'Script not found: ' + tried, exitCode: 1 } } const prevStatic = process.env.STATIC_ROOT process.env.STATIC_ROOT = path.resolve(staticDir) try { if (require.cache[resolvedPath]) delete require.cache[resolvedPath] const mod = require(resolvedPath) const out = scriptName === 'json-parser' ? mod.run(parameters[0], parameters[1], parameters[2], parameters[3]) : mod.run(parameters[0], parameters[1]) const exitCode = out.exitCode !== undefined ? out.exitCode : (out.success ? 0 : 1) // 与脚本 stdout 一致:read 且文件不存在时返回空,前端会据此创建文件 let stdout = JSON.stringify(out) if (scriptName === 'json-parser' && parameters[0] === 'read' && out.success && out.data === undefined) { stdout = '' } return { success: out.success, stdout, stderr: '', exitCode } } catch (e) { return { success: false, stdout: '', stderr: e.message || String(e), exitCode: 1 } } finally { if (prevStatic !== undefined) process.env.STATIC_ROOT = prevStatic else delete process.env.STATIC_ROOT } } return new Promise((resolve, reject) => { const scriptPath = path.resolve(unpackedRoot, 'nodejs', scriptName + '.js') const processKey = `${scriptName}-${parameters.join('-')}` if (runningProcesses.has(processKey)) { const oldProcess = runningProcesses.get(processKey) oldProcess.kill() runningProcesses.delete(processKey) } const nodeProcess = spawn(nodeExecutable, [scriptPath, ...parameters], { env: getNodeEnv(), cwd: unpackedRoot, windowsHide: true }) runningProcesses.set(processKey, nodeProcess) let stdout = '' let stderr = '' let resolved = false const finish = (result) => { if (resolved) return resolved = true resolve(result) } nodeProcess.stdout.on('data', (data) => { const dataStr = data.toString() stdout += dataStr const isLongRunning = scriptName.includes('screenshot') || scriptName.includes('adb/') try { const lines = dataStr.trim().split('\n') for (const line of lines) { if (line.trim().startsWith('{')) { const json = JSON.parse(line.trim()) if (json.success && isLongRunning) { finish({ success: true, stdout: line.trim(), stderr: stderr.trim(), exitCode: null }) return } } } } catch (e) {} }) nodeProcess.stderr.on('data', (data) => { stderr += data.toString() }) const waitForExit = scriptName === 'run-process' || scriptName === 'enable-wirless-connect' const timeoutId = waitForExit ? null : setTimeout(() => { if (!resolved) { finish({ success: false, stdout: stdout.trim(), stderr: stderr.trim(), exitCode: 1, message: 'Script is running in background' }) } }, 5000) nodeProcess.on('close', (code) => { if (timeoutId) clearTimeout(timeoutId) runningProcesses.delete(processKey) const exitCode = (code !== null && code !== undefined) ? code : 1 finish({ success: exitCode === 0, stdout: stdout.trim(), stderr: stderr.trim(), exitCode }) }) nodeProcess.on('error', (error) => { clearTimeout(timeoutId) runningProcesses.delete(processKey) if (!resolved) { resolved = true reject(error) } }) }) }) /** 停止指定的 Node.js 脚本进程 */ ipcMain.handle('kill-nodejs-script', async (event, scriptName, ...parameters) => { const processKey = `${scriptName}-${parameters.join('-')}` if (runningProcesses.has(processKey)) { runningProcesses.get(processKey).kill() runningProcesses.delete(processKey) return { killed: true } } return { killed: false } }) /** 检查 scrcpy 是否仍在运行(读 pid 文件并用 process.kill(pid,0) 探测),用于用户直接关窗口时同步按钮状态 */ ipcMain.handle('check-scrcpy-running', async () => { const pidFile = path.join(staticDir, 'scrcpy-pid.json') if (!fs.existsSync(pidFile)) return { running: false } let pid try { pid = JSON.parse(fs.readFileSync(pidFile, 'utf-8')).pid } catch (_) { return { running: false } } try { process.kill(pid, 0) return { running: true } } catch (_) { return { running: false } } }) // Execute Python script(解释器路径仅来自根目录 config.js,见 nodejs/python-exe-from-config) ipcMain.handle('run-python-script', async (event, scriptName, ...parameters) => { return new Promise((resolve, reject) => { const pythonPath = getPythonExeFromConfig(config) const scriptPath = path.join(unpackedRoot, 'python', 'scripts', `${scriptName}.py`) if (!fs.existsSync(scriptPath)) { reject({ success: false, error: `Script file not found: ${scriptPath}`, stderr: `Script file not found: ${scriptPath}` }) return } const pythonProcess = spawn(pythonPath, [scriptPath, ...parameters]) let stdout = '' let stderr = '' pythonProcess.stdout.on('data', (data) => { stdout += data.toString() }) pythonProcess.stderr.on('data', (data) => { stderr += data.toString() }) pythonProcess.on('close', (code) => { resolve({ success: code === 0, stdout: stdout.trim(), stderr: stderr.trim(), exitCode: code }) }) pythonProcess.on('error', (error) => { reject({ success: false, error: error.message, stderr: error.message }) }) }) }) // IPC 实时通信处理 // 处理前端请求(异步,返回 Promise) ipcMain.handle('ipc-request', async (event, channel, data) => { // 可以根据不同的 channel 处理不同的请求 // 例如:'run-nodejs', 'get-status' 等 if (channel === 'run-nodejs') { // 通过 IPC 调用 run-nodejs-script const { scriptName, ...parameters } = data return new Promise((resolve, reject) => { const scriptPath = path.join(unpackedRoot, 'nodejs', `${scriptName}.js`) const processKey = `${scriptName}-${parameters.join('-')}` if (runningProcesses.has(processKey)) { const oldProcess = runningProcesses.get(processKey) oldProcess.kill() runningProcesses.delete(processKey) } const nodeProcess = spawn(nodeExecutable, [scriptPath, ...Object.values(parameters)], { env: getNodeEnv(), cwd: unpackedRoot, windowsHide: true }) runningProcesses.set(processKey, nodeProcess) let stdout = '' let stderr = '' let resolved = false nodeProcess.stdout.on('data', (chunk) => { stdout += chunk.toString() try { const lines = chunk.toString().trim().split('\n') for (const line of lines) { if (line.trim().startsWith('{')) { const json = JSON.parse(line.trim()) if (json.success && !resolved) { resolved = true resolve({ success: true, stdout: line.trim(), stderr: stderr.trim(), exitCode: null }) } } } } catch (e) {} }) nodeProcess.stderr.on('data', (chunk) => { stderr += chunk.toString() }) nodeProcess.on('close', (code) => { runningProcesses.delete(processKey) if (!resolved) { resolve({ success: code === 0, stdout: stdout.trim(), stderr: stderr.trim(), exitCode: code }) } }) nodeProcess.on('error', (error) => { runningProcesses.delete(processKey) if (!resolved) { reject(error) } }) setTimeout(() => { if (!resolved) { resolved = true resolve({ success: true, stdout: stdout.trim(), stderr: stderr.trim(), exitCode: null, message: 'Script is running in background' }) } }, 5000) }) } // 默认响应 return { success: true, channel, data, timestamp: Date.now() } }) // 监听前端发送的消息(不需要响应) ipcMain.on('ipc-message', (event, channel, data) => { // 处理前端发送的消息 console.log(`[IPC] Received message on channel "${channel}":`, data) }) // 推送消息到前端的辅助函数 function pushToFrontend(channel, data) { if (mainWindowInstance && !mainWindowInstance.isDestroyed()) { mainWindowInstance.webContents.send(channel, data) } } // 导出推送函数供其他模块使用 global.pushToFrontend = pushToFrontend app.whenReady().then(() => { // Set Content-Security-Policy for packaged app (must run after app is ready) if (!isDev) { const { session } = require('electron') session.defaultSession.webRequest.onHeadersReceived((details, callback) => { const url = details.url || '' if (url.startsWith('file://')) { const csp = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:" callback({ responseHeaders: { ...details.responseHeaders, 'Content-Security-Policy': [csp] } }) } else { callback({ responseHeaders: details.responseHeaders }) } }) } startupLog('========== 本次启动 ==========') createWindow() app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { createWindow() } }) }) // 退出时:adb kill-server + taskkill;再按 exe 目录路径清理本包拉起的 node/python/adb(打包:exe 同目录 kill-all-process.ps1;开发:bat-tool) let adbKillDone = false function runKillProjectToolingScript () { if (process.platform !== 'win32') return try { const packagedKill = path.join(sandboxRoot, 'kill-all-process.ps1') const devKill = path.join(unpackedRoot, 'bat-tool', 'kill-all-process', 'kill-all-process.ps1') const killScript = app.isPackaged ? packagedKill : devKill const rootResolved = path.resolve(sandboxRoot) spawnSync('powershell.exe', [ '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', killScript, '-OnlyKillProcessesUnderPath', rootResolved ], { stdio: 'ignore', windowsHide: true, timeout: 20000 }) } catch (_) {} } function killAdbOnExit() { if (adbKillDone) return adbKillDone = true try { const adbPath = config?.adbPath?.path ? (path.isAbsolute(config.adbPath.path) ? config.adbPath.path : path.join(sandboxRoot, config.adbPath.path)) : path.join(sandboxRoot, 'scrcpy-adb', 'adb.exe') if (fs.existsSync(adbPath)) { try { spawnSync(adbPath, ['kill-server'], { stdio: 'ignore', windowsHide: true, timeout: 3000 }) } catch (_) {} } } catch (_) {} try { if (process.platform === 'win32') { spawnSync('taskkill', ['/IM', 'adb.exe', '/F'], { stdio: 'ignore', windowsHide: true, timeout: 5000 }) } else { spawnSync('pkill', ['-x', 'adb'], { stdio: 'ignore' }) } } catch (_) {} runKillProjectToolingScript() } app.on('before-quit', () => { killAdbOnExit() }) app.on('will-quit', () => { killAdbOnExit() }) app.on('window-all-closed', () => { killAdbOnExit() if (process.platform !== 'darwin') { app.quit() } })