main.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451
  1. import { app, BrowserWindow, dialog, Event, Input, ipcMain, Menu, WebContentsView } from 'electron'
  2. import { fileURLToPath } from 'node:url'
  3. import path from 'node:path'
  4. import fs from 'node:fs'
  5. import { initUpdater } from './updater'
  6. const __dirname = path.dirname(fileURLToPath(import.meta.url))
  7. // The built directory structure
  8. //
  9. // ├─┬─┬ dist
  10. // │ │ └── index.html
  11. // │ │
  12. // │ ├─┬ dist-electron
  13. // │ │ ├── main.js
  14. // │ │ └── preload.mjs
  15. // │
  16. process.env.APP_ROOT = path.join(__dirname, '..')
  17. // 🚧 Use ['ENV_NAME'] avoid vite:define plugin - Vite@2.x
  18. export const VITE_DEV_SERVER_URL = process.env['VITE_DEV_SERVER_URL']
  19. export const MAIN_DIST = path.join(process.env.APP_ROOT, 'dist-electron')
  20. export const RENDERER_DIST = path.join(process.env.APP_ROOT, 'dist')
  21. process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL ? path.join(process.env.APP_ROOT, 'public') : RENDERER_DIST
  22. let mainWindow: BrowserWindow | null
  23. let childView: WebContentsView | null
  24. let isSideOpen = true
  25. let childViewAspectRatio = 0;
  26. const SIDE_WIDTH = 250
  27. const EXPAND_VIEW_SIZE = 40
  28. const LOADING_VIEW_WIDTH = 150
  29. const LOADING_VIEW_HEIGHT = 60
  30. function loadWindowPage(window: BrowserWindow, subPath: string) {
  31. if (VITE_DEV_SERVER_URL) {
  32. window.loadURL(VITE_DEV_SERVER_URL + "#" + subPath)
  33. } else {
  34. window.loadFile(path.join(RENDERER_DIST, 'index.html'), {
  35. hash: subPath,
  36. })
  37. }
  38. }
  39. function loadViewUrl(view: WebContentsView, subPath: string) {
  40. if (!mainWindow) {
  41. return
  42. }
  43. if (VITE_DEV_SERVER_URL) {
  44. view.webContents.loadURL(VITE_DEV_SERVER_URL + "#" + subPath)
  45. } else {
  46. view.webContents.loadFile(path.join(RENDERER_DIST, 'index.html'), {
  47. hash: subPath,
  48. })
  49. }
  50. }
  51. Menu.setApplicationMenu(null)
  52. function createWindow() {
  53. mainWindow = new BrowserWindow({
  54. icon: path.join(process.env.VITE_PUBLIC, 'icon.ico'),
  55. webPreferences: {
  56. preload: path.join(__dirname, 'preload.mjs'),
  57. contextIsolation: true,
  58. devTools: true,
  59. allowRunningInsecureContent: true,
  60. },
  61. width: 1200,
  62. height: 800,
  63. })
  64. childView = new WebContentsView({
  65. webPreferences: {
  66. allowRunningInsecureContent: true,
  67. },
  68. })
  69. const expandButtonView = new WebContentsView({
  70. webPreferences: {
  71. preload: path.join(__dirname, 'preload.mjs'),
  72. contextIsolation: true,
  73. },
  74. })
  75. const loadingView = new BrowserWindow({
  76. skipTaskbar: true,
  77. width: LOADING_VIEW_WIDTH,
  78. height: LOADING_VIEW_HEIGHT,
  79. parent: mainWindow,
  80. thickFrame: true,
  81. titleBarStyle: 'hidden',
  82. webPreferences: {
  83. preload: path.join(__dirname, 'preload.mjs'),
  84. contextIsolation: true,
  85. },
  86. })
  87. mainWindow.contentView.addChildView(childView)
  88. mainWindow.contentView.addChildView(expandButtonView)
  89. expandButtonView.setVisible(false)
  90. childView.webContents.on('did-start-loading', () => {
  91. loadingView.show()
  92. });
  93. childView.webContents.on('did-stop-loading', () => {
  94. loadingView.hide()
  95. });
  96. childView.webContents.on('did-fail-load', (_, errorCode, errorDescription) => {
  97. loadingView.hide()
  98. loadViewUrl(childView!, '/error?code=' + errorCode + '&message=' + errorDescription);
  99. });
  100. function updateChildWindowBounds() {
  101. const bounds = mainWindow!.getBounds();
  102. expandButtonView.setBounds({
  103. x: 0,
  104. y: bounds.height - 100,
  105. width: EXPAND_VIEW_SIZE,
  106. height: EXPAND_VIEW_SIZE
  107. })
  108. loadingView.setBounds({
  109. x: bounds.x + (bounds.width - LOADING_VIEW_WIDTH) / 2,
  110. y: bounds.y + 30,
  111. width: LOADING_VIEW_WIDTH,
  112. height: LOADING_VIEW_HEIGHT
  113. })
  114. if (childViewAspectRatio) {
  115. // 保持子页纵横比
  116. const rect = {
  117. x: isSideOpen ? SIDE_WIDTH : 0,
  118. y: 0,
  119. width: bounds.width - (isSideOpen ? SIDE_WIDTH : 0),
  120. height: bounds.height
  121. };
  122. // 计算子窗口的最佳尺寸(contain模式)
  123. const availableRatio = rect.width / rect.height;
  124. let childWidth, childHeight;
  125. if (availableRatio > childViewAspectRatio) {
  126. // 高度受限制
  127. childHeight = rect.height;
  128. childWidth = childHeight * childViewAspectRatio;
  129. } else {
  130. // 宽度受限制
  131. childWidth = rect.width;
  132. childHeight = childWidth / childViewAspectRatio;
  133. }
  134. // 计算居中位置
  135. const childX = rect.x + (rect.width - childWidth) / 2;
  136. const childY = rect.y + (rect.height - childHeight) / 2;
  137. // 应用到子窗口
  138. childView!.setBounds({
  139. x: Math.round(childX),
  140. y: Math.round(childY),
  141. width: Math.round(childWidth),
  142. height: Math.round(childHeight)
  143. });
  144. } else {
  145. childView!.setBounds({
  146. x: isSideOpen ? SIDE_WIDTH : 0,
  147. y: 0,
  148. width: bounds.width - (isSideOpen ? SIDE_WIDTH : 0),
  149. height: bounds.height
  150. });
  151. }
  152. }
  153. loadWindowPage(mainWindow, '/');
  154. loadWindowPage(loadingView, '/loading');
  155. loadViewUrl(childView, '/hello');
  156. loadViewUrl(expandButtonView, '/expand');
  157. updateChildWindowBounds();
  158. initUpdater(mainWindow);
  159. mainWindow.on('resize', () => {
  160. updateChildWindowBounds()
  161. })
  162. mainWindow.webContents.on('did-finish-load', () => {
  163. mainWindow?.webContents.send('main-process-message', (new Date).toLocaleString())
  164. })
  165. function handleWindowFullScreenKeys(window: BrowserWindow | WebContentsView, event: Event, input: Input) {
  166. if (input.key === 'F11' && input.type === 'keyDown') {
  167. event.preventDefault();
  168. mainWindow?.setFullScreen(!mainWindow?.isFullScreen())
  169. } else if (input.key === 'F12' && input.type === 'keyDown') {
  170. event.preventDefault();
  171. if (window === mainWindow) {
  172. toggleDevTools()
  173. } else if (window === childView) {
  174. childView?.webContents.toggleDevTools()
  175. }
  176. } else if (input.key === 'F5' && input.type === 'keyDown') {
  177. event.preventDefault();
  178. if (window === mainWindow) {
  179. if (input.control)
  180. mainWindow?.webContents.reloadIgnoringCache()
  181. else
  182. mainWindow?.webContents.reload()
  183. } else if (window === childView) {
  184. if (input.control)
  185. childView?.webContents.reloadIgnoringCache()
  186. else
  187. childView?.webContents.reload()
  188. }
  189. }
  190. }
  191. function toggleDevTools() {
  192. if (mainWindow) {
  193. if (mainWindow.webContents.isDevToolsOpened()) {
  194. mainWindow.webContents.closeDevTools()
  195. } else {
  196. mainWindow.webContents.openDevTools({
  197. mode: 'detach',
  198. })
  199. }
  200. }
  201. }
  202. // 添加F11全屏切换功能
  203. mainWindow.webContents.on('before-input-event', (event, input) => {
  204. handleWindowFullScreenKeys(mainWindow!, event, input)
  205. })
  206. childView.webContents.on('before-input-event', (event, input) => {
  207. handleWindowFullScreenKeys(childView!, event, input)
  208. })
  209. // 处理退出应用事件
  210. ipcMain.on('exit-app', () => {
  211. app.quit()
  212. })
  213. // 处理全屏切换事件
  214. ipcMain.on('toggle-fullscreen', (_event, isFullScreen: boolean) => {
  215. if (mainWindow) {
  216. if (isFullScreen) {
  217. mainWindow.setFullScreen(true)
  218. } else {
  219. mainWindow.setFullScreen(false)
  220. }
  221. }
  222. })
  223. ipcMain.on('toggle-dev-tools', toggleDevTools)
  224. // 加载子页URL
  225. ipcMain.on('load-child-url', (_event, url: string, aspectRatio: number, inputPassword?: { username: string; password: string }) => {
  226. if (childView) {
  227. console.log('load-child-url', url, inputPassword)
  228. childView.webContents.loadURL(url)
  229. if (inputPassword) {
  230. childView.webContents.once('did-finish-load', () => {
  231. console.log('did-finish-load', inputPassword)
  232. const script = `
  233. (function() {
  234. function tryFill() {
  235. const passwordInputs = document.querySelectorAll('input[type="password"]');
  236. if (passwordInputs.length === 0) return false;
  237. const allInputs = Array.from(document.querySelectorAll('input'));
  238. const usernameInput = allInputs.find(el => {
  239. const type = el.getAttribute('type');
  240. return !type || type === 'text' || type === 'email';
  241. });
  242. const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
  243. if (usernameInput) {
  244. nativeInputValueSetter.call(usernameInput, ${JSON.stringify(inputPassword.username)});
  245. usernameInput.dispatchEvent(new Event('input', { bubbles: true }));
  246. usernameInput.dispatchEvent(new Event('change', { bubbles: true }));
  247. }
  248. const pwdInput = passwordInputs[0];
  249. nativeInputValueSetter.call(pwdInput, ${JSON.stringify(inputPassword.password)});
  250. pwdInput.dispatchEvent(new Event('input', { bubbles: true }));
  251. pwdInput.dispatchEvent(new Event('change', { bubbles: true }));
  252. return true;
  253. }
  254. let attempts = 0;
  255. const timer = setInterval(() => {
  256. if (tryFill() || ++attempts >= 10) clearInterval(timer);
  257. }, 500);
  258. })();
  259. `;
  260. childView!.webContents.executeJavaScript(script).catch(() => {});
  261. });
  262. }
  263. }
  264. childViewAspectRatio = aspectRatio
  265. updateChildWindowBounds()
  266. })
  267. // 子页侧边栏开关
  268. ipcMain.on('toggle-child-side', (_event, value: boolean) => {
  269. isSideOpen = value
  270. expandButtonView.setVisible(!isSideOpen)
  271. mainWindow?.webContents.send('main-side-state-changed', isSideOpen)
  272. updateChildWindowBounds()
  273. })
  274. // 处理获取应用路径事件
  275. ipcMain.handle('get-app-path', () => {
  276. return app.getAppPath()
  277. })
  278. // 处理打开窗口事件
  279. ipcMain.on('open-window', (_event, url: string) => {
  280. const newWin = new BrowserWindow({
  281. icon: path.join(process.env.VITE_PUBLIC, 'icon.ico'),
  282. webPreferences: {
  283. preload: path.join(__dirname, 'preload.mjs'),
  284. contextIsolation: true,
  285. allowRunningInsecureContent: true,
  286. },
  287. fullscreenable: true,
  288. maximizable: true,
  289. width: 1200,
  290. height: 800,
  291. })
  292. newWin.loadURL(url)
  293. newWin.maximize();
  294. // 添加F11全屏切换功能
  295. newWin.webContents.on('before-input-event', (event, input) => {
  296. if (input.key === 'F11' && input.type === 'keyDown') {
  297. event.preventDefault();
  298. newWin?.setFullScreen(!newWin?.fullScreen)
  299. } else if (input.key === 'F12' && input.type === 'keyDown') {
  300. event.preventDefault();
  301. newWin?.webContents.toggleDevTools();
  302. }
  303. })
  304. })
  305. // 处理加载apps.json事件
  306. ipcMain.handle('load-apps-json', async () => {
  307. const appPath = process.cwd()
  308. const appsJsonPath = path.join(appPath, 'apps.json')
  309. try {
  310. if (fs.existsSync(appsJsonPath)) {
  311. const data = fs.readFileSync(appsJsonPath, 'utf8')
  312. return JSON.parse(data)
  313. } else {
  314. // 开发环境下回退到public目录
  315. const devAppsJsonPath = path.join(process.env.VITE_PUBLIC || '', 'apps.json')
  316. if (fs.existsSync(devAppsJsonPath)) {
  317. const data = fs.readFileSync(devAppsJsonPath, 'utf8')
  318. return JSON.parse(data)
  319. }
  320. throw new Error('apps.json not found')
  321. }
  322. } catch (error) {
  323. console.error('Error loading apps.json:', error)
  324. throw error
  325. }
  326. })
  327. // 处理加载默认apps.json事件
  328. ipcMain.handle('load-default-apps-json', async () => {
  329. const devAppsJsonPath = path.join(process.env.VITE_PUBLIC || '', 'apps.json')
  330. if (fs.existsSync(devAppsJsonPath)) {
  331. const data = fs.readFileSync(devAppsJsonPath, 'utf8')
  332. return JSON.parse(data)
  333. }
  334. throw new Error('apps.json not found')
  335. })
  336. // 处理显示配置窗口事件
  337. ipcMain.on('show-config', () => {
  338. const configWindow = new BrowserWindow({
  339. icon: path.join(process.env.VITE_PUBLIC, 'icon.ico'),
  340. webPreferences: {
  341. preload: path.join(__dirname, 'preload.mjs'),
  342. contextIsolation: true,
  343. allowRunningInsecureContent: true,
  344. },
  345. title: '列表配置',
  346. parent: mainWindow || undefined,
  347. skipTaskbar: true,
  348. minimizable: false,
  349. maximizable: false,
  350. modal: true,
  351. width: 800,
  352. height: 600,
  353. })
  354. loadWindowPage(configWindow, '/config')
  355. });
  356. // 处理保存apps.json事件
  357. ipcMain.on('save-apps-json', (_event, appsJson: string) => {
  358. const appPath = process.cwd()
  359. const appsJsonPath = path.join(appPath, 'apps.json')
  360. try {
  361. fs.writeFileSync(appsJsonPath, appsJson)
  362. mainWindow?.webContents.send('main-config-changed')
  363. } catch (error) {
  364. console.error('Error saving apps.json:', error)
  365. throw error
  366. }
  367. })
  368. // 处理显示关于窗口事件
  369. ipcMain.handle('clear-cache', async () => {
  370. if (mainWindow) {
  371. const res = await dialog.showMessageBox(mainWindow, {
  372. title: '提示',
  373. message: '是否要清除缓存?',
  374. type: 'question',
  375. buttons: ['取消', '确定'],
  376. defaultId: 0,
  377. noLink: true,
  378. });
  379. if (res.response === 0)
  380. return
  381. await mainWindow.webContents.session.clearCache()
  382. if (childView) {
  383. await childView.webContents.session.clearCache()
  384. childView.webContents.reloadIgnoringCache();
  385. }
  386. }
  387. })
  388. ipcMain.on('show-about', () => {
  389. const aboutWindow = new BrowserWindow({
  390. icon: path.join(process.env.VITE_PUBLIC, 'icon.ico'),
  391. webPreferences: {
  392. preload: path.join(__dirname, 'preload.mjs'),
  393. contextIsolation: true,
  394. allowRunningInsecureContent: true,
  395. },
  396. parent: mainWindow || undefined,
  397. title: '关于程序',
  398. skipTaskbar: true,
  399. maximizable: false,
  400. minimizable: false,
  401. modal: true,
  402. width: 450,
  403. height: 470,
  404. })
  405. loadWindowPage(aboutWindow, '/about')
  406. });
  407. }
  408. // Quit when all mainWindowdows are closed, except on macOS. There, it's common
  409. // for applications and their menu bar to stay active until the user quits
  410. // explicitly with Cmd + Q.
  411. app.on('window-all-closed', () => {
  412. if (process.platform !== 'darwin') {
  413. app.quit()
  414. mainWindow = null
  415. }
  416. })
  417. app.on('activate', () => {
  418. // On OS X it's common to re-create a mainWindowdow in the app when the
  419. // dock icon is clicked and there are no other mainWindowdows open.
  420. if (BrowserWindow.getAllWindows().length === 0) {
  421. createWindow()
  422. }
  423. })
  424. app.whenReady().then(createWindow)