utils.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597
  1. 'use strict';
  2. const { Controller } = require('ee-core');
  3. const { shell } = require('electron');
  4. const Addon = require('ee-core/addon');
  5. const { dialog } = require('electron');
  6. const fs = require('fs');
  7. const path = require('path');
  8. const CoreWindow = require('ee-core/electron/window');
  9. const { BrowserWindow, Menu,app } = require('electron');
  10. const { spawn } = require('child_process');
  11. const Log = require('ee-core/log');
  12. const { readConfigFile } = require('../utils/config');
  13. const configDeault = readConfigFile();
  14. const { t } = require('../config/i18n');
  15. const sharp = require('sharp'); // 确保安装:npm install sharp
  16. /**
  17. * example
  18. * @class
  19. */
  20. class UtilsController extends Controller {
  21. constructor(ctx) {
  22. super(ctx);
  23. }
  24. /**
  25. * 运行外部工具(如exe)
  26. * @param {{exeName?: string, exePath?: string, args?: string[]}} params
  27. */
  28. async runExternalTool (params = {}) {
  29. try {
  30. const { exeName, exePath, args = [] } = params;
  31. const targetName = exePath || exeName;
  32. if (!targetName) {
  33. throw new Error(t('utils.missingExeName'));
  34. }
  35. const isPackaged = app.isPackaged;
  36. const execDir = isPackaged ? path.dirname(app.getPath('exe')) : path.join(app.getAppPath(), '.');
  37. const resourcesDir = process.resourcesPath;
  38. const candidates = [];
  39. const pushCandidate = (candidatePath, isAbsolute = false) => {
  40. if (!candidatePath) return;
  41. const normalized = isAbsolute || path.isAbsolute(candidatePath)
  42. ? candidatePath
  43. : path.join(execDir, candidatePath);
  44. if (!candidates.includes(normalized)) {
  45. candidates.push(normalized);
  46. }
  47. };
  48. // 允许前端显式传入候选路径数组
  49. if (Array.isArray(params.candidatePaths)) {
  50. params.candidatePaths.forEach((p) => pushCandidate(p));
  51. }
  52. if (path.isAbsolute(targetName)) {
  53. pushCandidate(targetName, true);
  54. } else {
  55. if (isPackaged) {
  56. pushCandidate(path.join(execDir, targetName), true);
  57. pushCandidate(path.join(resourcesDir, targetName), true);
  58. pushCandidate(path.join(resourcesDir, 'extraResources', targetName), true);
  59. } else {
  60. pushCandidate(targetName);
  61. pushCandidate(path.join('build', 'extraResources', targetName));
  62. pushCandidate(path.join('extraResources', targetName));
  63. }
  64. }
  65. console.log('=======');
  66. console.log(candidates);
  67. const resolvedPath = candidates.find((filePath) => filePath && fs.existsSync(filePath));
  68. if (!resolvedPath) {
  69. throw new Error(t('utils.toolNotFound', { name: targetName }));
  70. }
  71. // 如果已经有正在运行的子进程,则先关闭旧进程,再重新启动新的(保证始终只有一个进程,同时刷新页面参数)
  72. if (this.app.externalToolProcess && !this.app.externalToolProcess.killed) {
  73. try {
  74. this.app.externalToolProcess.kill();
  75. } catch (e) {
  76. console.warn('关闭旧 externalTool 进程失败(忽略继续启动新进程):', e);
  77. }
  78. this.app.externalToolProcess = null;
  79. }
  80. const child = spawn(resolvedPath, args, {
  81. cwd: path.dirname(resolvedPath),
  82. detached: true,
  83. stdio: 'ignore',
  84. windowsHide: false
  85. });
  86. // 记录子进程,方便后续复用/判断是否已退出
  87. this.app.externalToolProcess = child;
  88. child.on('exit', () => {
  89. if (this.app.externalToolProcess === child) {
  90. this.app.externalToolProcess = null;
  91. }
  92. });
  93. child.unref();
  94. return {
  95. code: 0,
  96. data: {
  97. path: resolvedPath,
  98. reused: false
  99. }
  100. };
  101. } catch (error) {
  102. console.error('runExternalTool error:', error);
  103. return {
  104. code: 1,
  105. msg: error.message || t('utils.runExternalToolFailed')
  106. };
  107. }
  108. }
  109. /**
  110. * 所有方法接收两个参数
  111. * @param args 前端传的参数
  112. * @param event - ipc通信时才有值。详情见:控制器文档
  113. */
  114. /**
  115. * upload
  116. */
  117. async shellFun (params) {
  118. // 如果是打开路径操作,确保路径存在
  119. if (params.action === 'openMkPath') {
  120. try {
  121. // 确保目录存在,如果不存在就创建
  122. if (!fs.existsSync(params.params)) {
  123. fs.mkdirSync(params.params, { recursive: true });
  124. }
  125. shell.openPath(params.params)
  126. return;
  127. } catch (err) {
  128. console.error('创建目录失败:', err);
  129. // 即使创建目录失败,也尝试打开路径(可能已经存在)
  130. }
  131. }
  132. shell[params.action](params.params)
  133. }
  134. async openMain (config) {
  135. const { id, url } = config;
  136. if (this.app.electron[id]) {
  137. const win = this.app.electron[id];
  138. // 切换到指定的 URL
  139. await win.loadURL(url);
  140. win.focus();
  141. win.show();
  142. return;
  143. }
  144. const win = new BrowserWindow({
  145. ...config,
  146. webPreferences: {
  147. webSecurity: false,
  148. contextIsolation: false, // false -> 可在渲染进程中使用electron的api,true->需要bridge.js(contextBridge)
  149. nodeIntegration: true,
  150. // preload: path.join('../preload/preload.js','../preload/bridge.js'),
  151. },
  152. });
  153. await win.loadURL(config.url); // 设置窗口的 URL
  154. // 监听窗口关闭事件
  155. if(configDeault.debug) win.webContents.openDevTools()
  156. //
  157. win.on('close', () => {
  158. delete this.app.electron[config.id]; // 删除窗口引用
  159. });
  160. this.app.electron[config.id] = win ;
  161. }
  162. async openDirectory(optiops={
  163. title: t('utils.selectFolder')
  164. }){
  165. const filePaths = dialog.showOpenDialogSync({
  166. title:optiops.title || t('utils.selectFolder'),
  167. properties: ['openDirectory']
  168. })
  169. if(filePaths[0]) return filePaths[0];
  170. return filePaths
  171. }
  172. /**
  173. * 关闭所有子窗口
  174. */
  175. closeAllWindows() {
  176. try {
  177. // 获取所有窗口
  178. const windows = this.app.electron;
  179. // 关闭除主窗口外的所有窗口
  180. for (const [id, window] of Object.entries(windows)) {
  181. if (!['mainWindow','extra'].includes(id)) { // 保留主窗口
  182. try {
  183. window.close();
  184. delete this.app.electron[id];
  185. } catch (error) {
  186. console.error(`关闭窗口 ${id} 失败:`, error);
  187. }
  188. }
  189. }
  190. console.log('所有子窗口已关闭');
  191. return { success: true, message: '所有子窗口已关闭' };
  192. } catch (error) {
  193. console.error('关闭所有窗口失败:', error);
  194. return { success: false, message: '关闭所有窗口失败: ' + error.message };
  195. }
  196. }
  197. async openImage(
  198. optiops= {
  199. title: t('utils.selectImage'),
  200. filters:[
  201. { name: t('utils.supportImageFormats'), extensions: ['jpg','jpeg','png'] },
  202. ],
  203. }
  204. ){
  205. const filePaths = dialog.showOpenDialogSync({
  206. title:optiops.title || t('utils.selectImage'),
  207. properties:['openFile'],
  208. filters:optiops.filters || [
  209. { name: t('utils.supportImageFormats'), extensions: ['jpg','jpeg','png'] },
  210. ]
  211. })
  212. const filePath = filePaths[0];
  213. const fileBuffer = fs.readFileSync(filePath);
  214. const base64Image = fileBuffer.toString('base64');
  215. // 获取文件扩展名
  216. const extension = path.extname(filePath).toLowerCase().replace('.', '');
  217. // 根据扩展名确定 MIME 类型
  218. let mimeType = '';
  219. switch (extension) {
  220. case 'jpg':
  221. case 'jpeg':
  222. mimeType = 'image/jpeg';
  223. break;
  224. case 'png':
  225. mimeType = 'image/png';
  226. break;
  227. case 'gif':
  228. mimeType = 'image/gif';
  229. break;
  230. default:
  231. mimeType = 'application/octet-stream'; // 默认 MIME 类型
  232. break;
  233. }
  234. // 构建 data URL
  235. const dataUrl = `data:${mimeType};base64,${base64Image}`;
  236. return {
  237. filePath:filePath,
  238. base64Image:dataUrl
  239. };
  240. }
  241. /**
  242. * 将前端生成的详情图数据写入 EXE 同级目录下的 output 文件夹。
  243. * @param {{ bundles: Array }} payload
  244. */
  245. async saveGeneratedImages (payload = {}) {
  246. try {
  247. const { app } = require('electron');
  248. const bundles = payload.bundles || [];
  249. if (!Array.isArray(bundles) || !bundles.length) {
  250. return { code: 1, msg: t('utils.noImageData') };
  251. }
  252. // 运行目录:打包后为 EXE 所在目录,开发环境为项目根目录
  253. const isPackaged = app.isPackaged;
  254. const exeDir = isPackaged ? path.dirname(app.getPath('exe')) : process.cwd();
  255. const baseOutputDir = path.join(exeDir, 'output');
  256. if (!fs.existsSync(baseOutputDir)) {
  257. fs.mkdirSync(baseOutputDir, { recursive: true });
  258. }
  259. let fileCount = 0;
  260. const saveDataUrlToFile = (dataUrl, targetPath) => {
  261. if (!dataUrl || typeof dataUrl !== 'string') return;
  262. const match = dataUrl.match(/^data:image\/\w+;base64,(.+)$/);
  263. const base64Data = match ? match[1] : dataUrl;
  264. const buffer = Buffer.from(base64Data, 'base64');
  265. const dir = path.dirname(targetPath);
  266. if (!fs.existsSync(dir)) {
  267. fs.mkdirSync(dir, { recursive: true });
  268. }
  269. fs.writeFileSync(targetPath, buffer);
  270. fileCount += 1;
  271. };
  272. bundles.forEach(bundle => {
  273. if (!bundle) return;
  274. const styleNo = (bundle.styleNo || bundle.styleKey || 'UNKNOWN').toString().replace(/[\\\/:*?"<>|]/g, '_');
  275. const styleDir = path.join(baseOutputDir, styleNo);
  276. if (!fs.existsSync(styleDir)) {
  277. fs.mkdirSync(styleDir, { recursive: true });
  278. }
  279. // 单画布图片
  280. (bundle.images || []).forEach((img, idx) => {
  281. if (!img || typeof img.dataUrl !== 'string') return;
  282. if (img.dataUrl === 'model' || img.dataUrl === 'scene') return;
  283. if (!img.dataUrl.startsWith('data:image')) return;
  284. const fileName = `canvas_${img.canvasIndex || 0}_img_${idx}.jpg`;
  285. const targetPath = path.join(styleDir, fileName);
  286. saveDataUrlToFile(img.dataUrl, targetPath);
  287. });
  288. // 合成长图
  289. if (bundle.combined && bundle.combined.dataUrl) {
  290. const combinedPath = path.join(styleDir, 'all_canvases.jpg');
  291. saveDataUrlToFile(bundle.combined.dataUrl, combinedPath);
  292. }
  293. });
  294. return {
  295. code: 0,
  296. data: {
  297. outputDir: baseOutputDir,
  298. fileCount,
  299. },
  300. };
  301. } catch (error) {
  302. console.error('saveGeneratedImages error:', error);
  303. return {
  304. code: 1,
  305. msg: error.message || t('utils.saveImageFailed'),
  306. };
  307. }
  308. }
  309. async openFile(optiops= {
  310. title: t('utils.selectFile'),
  311. filters:[
  312. { name: t('utils.supportImageFormats'), extensions: ['jpg','jpeg'] },
  313. ],
  314. }){
  315. const filePaths = dialog.showOpenDialogSync({
  316. title:optiops.title || t('utils.selectFile'),
  317. properties: ['openFile'],
  318. filters: optiops.filters || [
  319. { name: t('utils.selectFile') },
  320. ]
  321. })
  322. if(filePaths[0]) return filePaths[0];
  323. return filePaths
  324. }
  325. getAppConfig(){
  326. const config = readConfigFile()
  327. const appPath = path.join(app.getAppPath(), '../..');
  328. const pyPath = path.join(path.dirname(appPath), 'extraResources', 'py');
  329. return {
  330. ...config,
  331. userDataPath: app.getPath('userData'),
  332. appPath: appPath,
  333. pyPath:pyPath,
  334. }
  335. }
  336. // 字体管理相关方法
  337. async downloadFont(args, event) {
  338. try {
  339. console.log('🔤 IPC downloadFont called with args:', args, 'event:', event)
  340. const { url, fontName } = args;
  341. console.log('🔤 IPC downloadFont parsed:', { url, fontName })
  342. Log.info('downloadFontdownloadFontdownloadFontdownloadFont:', url, fontName)
  343. const https = require('https');
  344. const http = require('http');
  345. const fs = require('fs');
  346. const path = require('path');
  347. const crypto = require('crypto');
  348. // 获取字体缓存目录
  349. const userDataPath = app.getPath('userData');
  350. const fontCacheDir = path.join(userDataPath, 'fonts');
  351. // 确保字体缓存目录存在
  352. if (!fs.existsSync(fontCacheDir)) {
  353. fs.mkdirSync(fontCacheDir, { recursive: true });
  354. }
  355. // 生成字体文件名(使用URL的哈希值避免文件名冲突)
  356. const urlHash = crypto.createHash('md5').update(url).digest('hex').substring(0, 8);
  357. const fontFileName = `${fontName.replace(/[^a-zA-Z0-9]/g, '_')}_${urlHash}.ttf`;
  358. const localFontPath = path.join(fontCacheDir, fontFileName);
  359. // 检查本地是否已有字体文件
  360. if (fs.existsSync(localFontPath)) {
  361. console.log('字体已存在:', localFontPath);
  362. return { success: true, path: localFontPath };
  363. }
  364. console.log('开始下载字体:', fontName, '到:', localFontPath);
  365. // 下载字体文件
  366. const protocol = url.startsWith('https:') ? https : http;
  367. return new Promise((resolve, reject) => {
  368. const request = protocol.get(url, (response) => {
  369. if (response.statusCode !== 200) {
  370. reject(new Error(`Failed to download font: ${response.statusCode}`));
  371. return;
  372. }
  373. const fileStream = fs.createWriteStream(localFontPath);
  374. response.pipe(fileStream);
  375. fileStream.on('finish', () => {
  376. fileStream.close();
  377. console.log('字体下载完成:', localFontPath);
  378. resolve({ success: true, path: localFontPath });
  379. });
  380. fileStream.on('error', (error) => {
  381. fs.unlinkSync(localFontPath); // 删除失败的文件
  382. reject(error);
  383. });
  384. });
  385. request.on('error', (error) => {
  386. reject(error);
  387. });
  388. // 设置超时
  389. request.setTimeout(30000, () => {
  390. request.abort();
  391. reject(new Error('Font download timeout'));
  392. });
  393. });
  394. } catch (error) {
  395. Log.info('downloadFont Error:', error);
  396. return { success: false, error: error.message };
  397. }
  398. }
  399. async loadFontFromCache(args, event) {
  400. try {
  401. const { fontName, fontUrl } = args;
  402. const fs = require('fs');
  403. const path = require('path');
  404. const crypto = require('crypto');
  405. // 获取字体缓存目录
  406. const userDataPath = app.getPath('userData');
  407. const fontCacheDir = path.join(userDataPath, 'fonts');
  408. // 生成字体文件名
  409. const urlHash = crypto.createHash('md5').update(fontUrl).digest('hex').substring(0, 8);
  410. const fontFileName = `${fontName.replace(/[^a-zA-Z0-9]/g, '_')}_${urlHash}.ttf`;
  411. const localFontPath = path.join(fontCacheDir, fontFileName);
  412. // 检查本地是否已有字体文件
  413. if (fs.existsSync(localFontPath)) {
  414. // 读取字体文件内容并转换为base64
  415. const fontData = fs.readFileSync(localFontPath);
  416. const base64Data = fontData.toString('base64');
  417. const dataUrl = `data:font/ttf;base64,${base64Data}`;
  418. return { success: true, dataUrl, path: localFontPath };
  419. }
  420. return { success: false, message: 'Font not found in cache' };
  421. } catch (error) {
  422. console.error('从缓存加载字体失败:', error);
  423. return { success: false, error: error.message };
  424. }
  425. }
  426. getFontCachePath(args, event) {
  427. const path = require('path');
  428. const userDataPath = app.getPath('userData');
  429. return path.join(userDataPath, 'fonts');
  430. }
  431. // 测试方法
  432. async testIPC(args, event) {
  433. console.log('🧪 IPC test method called:', args)
  434. return { success: true, message: 'IPC is working', args }
  435. }
  436. // 设置语言(由前端调用,保持 Electron 和前端语言同步)
  437. async setLanguage(args, event) {
  438. try {
  439. const { language } = args;
  440. const fs = require('fs');
  441. // 将语言设置写入缓存文件
  442. const userDataPath = app.getPath('userData');
  443. const langCachePath = path.join(userDataPath, 'language.json');
  444. fs.writeFileSync(langCachePath, JSON.stringify({ language }), 'utf-8');
  445. // 重新加载 i18n
  446. const i18n = require('../config/i18n');
  447. i18n.reloadTranslations();
  448. console.log('Language set to:', language);
  449. return { success: true, language };
  450. } catch (error) {
  451. console.error('Failed to set language:', error);
  452. return { success: false, error: error.message };
  453. }
  454. }
  455. async readFileImageForPath(filePath,maxWidth=1500){
  456. const getMimeType = (fileName)=>{
  457. const extension = path.extname(fileName).toLowerCase().replace('.', '');
  458. let mimeType = '';
  459. switch (extension) {
  460. case 'jpg':
  461. case 'jpeg':
  462. mimeType = 'image/jpeg';
  463. break;
  464. case 'png':
  465. mimeType = 'image/png';
  466. break;
  467. case 'gif':
  468. mimeType = 'image/gif';
  469. break;
  470. case 'webp':
  471. mimeType = 'image/webp';
  472. break;
  473. case 'avif':
  474. mimeType = 'image/avif';
  475. break;
  476. default:
  477. mimeType = 'application/octet-stream';
  478. break;
  479. }
  480. return mimeType;
  481. }
  482. try {
  483. const fileName = path.basename(filePath);
  484. const image = sharp(filePath);
  485. const metadata = await image.metadata();
  486. let mimeType = getMimeType(fileName); // 调用下面定义的私有方法获取 MIME 类型
  487. let fileBuffer;
  488. if (metadata.width > maxWidth) {
  489. // 如果宽度大于 1500px,压缩至 1500px 宽度,保持比例
  490. fileBuffer = await image.resize(maxWidth).toBuffer();
  491. } else {
  492. // 否则直接读取原图
  493. fileBuffer = fs.readFileSync(filePath);
  494. }
  495. return {
  496. fileBuffer,
  497. fileName,
  498. mimeType
  499. };
  500. } catch (error) {
  501. console.error('Error processing image:', error);
  502. throw error;
  503. }
  504. }
  505. }
  506. UtilsController.toString = () => '[class ExampleController]';
  507. module.exports = UtilsController;