main.mjs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163
  1. import { app, BrowserWindow, ipcMain, Menu, Tray, nativeImage, shell, utilityProcess, powerMonitor, systemPreferences, dialog, safeStorage } from 'electron'
  2. import { appendFileSync, mkdirSync, readFileSync } from 'node:fs'
  3. import { join, dirname } from 'node:path'
  4. import { homedir } from 'node:os'
  5. import { fileURLToPath, pathToFileURL } from 'node:url'
  6. import { DesktopServiceManager } from './service-manager.mjs'
  7. import { DesktopRunnerManager } from './runner-manager.mjs'
  8. const desktopRoot = dirname(fileURLToPath(import.meta.url))
  9. const fixtureHome = process.env.HERMES_YAOYAO_DESKTOP_TEST_HOME
  10. // Fixture identity also isolates Chromium cookies, window state and the app lock.
  11. if (fixtureHome) app.setPath('userData', join(fixtureHome, 'desktop'))
  12. app.setName('夭夭')
  13. if (!app.requestSingleInstanceLock()) { app.quit() }
  14. else {
  15. let window, tray, manager, runnerManager, quitting = false, closing = false, timer
  16. const bootURL = pathToFileURL(join(desktopRoot, 'boot.html')).href
  17. const root = app.isPackaged ? join(process.resourcesPath, 'runtime') : join(app.getAppPath(), '.desktop-build')
  18. const logRoot = join(app.getPath('logs'), fixtureHome ? 'verification' : 'service')
  19. mkdirSync(logRoot, { recursive: true })
  20. const logFile = join(logRoot, 'server.log')
  21. const log = text => appendFileSync(logFile, `${new Date().toISOString()} ${text.trim()}\n`, { mode: 0o600 })
  22. const home = fixtureHome || process.env.HERMES_YAOYAO_HOME || join(homedir(), '.hermes-yaoyao')
  23. const port = Number(process.env.HERMES_YAOYAO_DESKTOP_PORT || 15300)
  24. if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('桌面服务端口无效')
  25. const trustedBoot = event => event.sender === window?.webContents && event.senderFrame?.url === bootURL
  26. ipcMain.handle('desktop:status', event => {
  27. if (!trustedBoot(event)) throw new Error('不允许此页面访问桌面服务')
  28. return { phase: manager?.state.phase || 'starting', message: manager?.state.message || '正在启动…' }
  29. })
  30. ipcMain.handle('desktop:retry', async event => {
  31. if (!trustedBoot(event)) throw new Error('不允许此页面启动桌面服务')
  32. manager.restarts = []
  33. try { await manager.start(); return true } catch { return false }
  34. })
  35. ipcMain.handle('desktop:logs', event => {
  36. if (!trustedBoot(event)) throw new Error('不允许此页面读取日志')
  37. shell.showItemInFolder(logFile)
  38. })
  39. function show() {
  40. if (!window || window.isDestroyed()) createWindow()
  41. window.show(); window.focus()
  42. }
  43. function safeExternal(url) {
  44. try { const parsed = new URL(url); if (['https:', 'http:', 'mailto:'].includes(parsed.protocol)) void shell.openExternal(url) }
  45. catch { /* No arbitrary file, shell or custom URL dispatch. */ }
  46. }
  47. function createWindow() {
  48. window = new BrowserWindow({ width: 1280, height: 860, minWidth: 720, minHeight: 560, show: false,
  49. title: '夭夭', backgroundColor: '#f7f7f7',
  50. webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true,
  51. webSecurity: true, preload: join(desktopRoot, 'preload.cjs') } })
  52. window.webContents.setWindowOpenHandler(({ url }) => {
  53. try {
  54. if (manager?.state.url && new URL(url).origin === manager.state.url) void window.loadURL(url)
  55. else safeExternal(url)
  56. } catch { /* Invalid navigation is denied. */ }
  57. return { action: 'deny' }
  58. })
  59. const guardNavigation = (event, url) => {
  60. if (url === bootURL || (manager?.state.url && new URL(url).origin === manager.state.url)) return
  61. event.preventDefault(); safeExternal(url)
  62. }
  63. window.webContents.on('will-navigate', guardNavigation)
  64. window.webContents.on('will-redirect', guardNavigation)
  65. window.webContents.on('will-attach-webview', event => event.preventDefault())
  66. const trustedService = (contents, url) => {
  67. try { return contents === window?.webContents && !!manager?.state.url && new URL(url).origin === manager.state.url }
  68. catch { return false }
  69. }
  70. window.webContents.session.setPermissionCheckHandler((contents, permission, origin) =>
  71. trustedService(contents, origin) && ['media', 'notifications', 'clipboard-sanitized-write'].includes(permission))
  72. window.webContents.session.setPermissionRequestHandler((contents, permission, callback, details) => {
  73. if (!trustedService(contents, details.requestingUrl)) { callback(false); return }
  74. if (permission === 'media' && details.mediaTypes?.length && details.mediaTypes.every(type => type === 'audio')) {
  75. void systemPreferences.askForMediaAccess('microphone').then(callback, () => callback(false))
  76. } else callback(['notifications', 'clipboard-sanitized-write', 'fullscreen'].includes(permission))
  77. })
  78. window.on('close', event => { if (!quitting) { event.preventDefault(); window.hide() } })
  79. window.once('ready-to-show', () => window.show())
  80. if (manager?.state.phase === 'ready') void window.loadURL(manager.state.url)
  81. else void window.loadFile(join(desktopRoot, 'boot.html'))
  82. }
  83. function stateChanged(state) {
  84. log(`${state.phase}: ${state.message}`)
  85. tray?.setToolTip(`夭夭 · ${state.message}`)
  86. if (!window || window.isDestroyed()) return
  87. if (state.phase === 'ready') {
  88. const current = window.webContents.getURL()
  89. if (!current.startsWith(`${state.url}/`) && current !== state.url) void window.loadURL(state.url)
  90. } else if (window.webContents.getURL() !== bootURL) void window.loadFile(join(desktopRoot, 'boot.html'))
  91. }
  92. app.on('second-instance', show)
  93. app.on('activate', show)
  94. app.on('window-all-closed', () => {})
  95. app.on('before-quit', event => {
  96. if (quitting) return
  97. event.preventDefault()
  98. if (closing) return
  99. closing = true; clearInterval(timer)
  100. void (async()=>{
  101. try{await runnerManager?.stop();await manager?.stop();quitting=true;app.quit()}
  102. catch(error){
  103. log(error.message)
  104. const choice=await dialog.showMessageBox(window,{type:'warning',message:'服务清理尚未完成',detail:error.message,buttons:['继续运行','仍然退出'],defaultId:0,cancelId:0})
  105. if(choice.response===1){quitting=true;app.quit()}else{closing=false;timer=setInterval(()=>{void manager.check()},10000);timer.unref()}
  106. }
  107. })()
  108. })
  109. app.whenReady().then(async () => {
  110. const packageVersion = JSON.parse(readFileSync(join(root, 'release.json'), 'utf8')).webVersion
  111. manager = new DesktopServiceManager({ home, port, version: packageVersion, log, onState: stateChanged,
  112. fork: ({ home, port }) => {
  113. const env = { ...process.env, NODE_ENV: 'production', NODE_USE_ENV_PROXY: '0',
  114. PATH: [join(homedir(), '.local/bin'), '/opt/homebrew/bin', '/usr/local/bin', process.env.PATH || '/usr/bin:/bin'].join(':'),
  115. HERMES_YAOYAO_DESKTOP: '1', HERMES_YAOYAO_HOME: home, HERMES_YAOYAO_HOST: '127.0.0.1',
  116. HERMES_YAOYAO_PORT: String(port), HERMES_YAOYAO_STATIC_DIR: join(root, 'ui'),
  117. HERMES_YAOYAO_SUPERVISE_DASHBOARD: fixtureHome ? '0' : '1',
  118. HERMES_YAOYAO_UPSTREAM: process.env.HERMES_YAOYAO_UPSTREAM || 'http://127.0.0.1:9119' }
  119. delete env.ELECTRON_RUN_AS_NODE
  120. delete env.HERMES_YAOYAO_TLS_CERT; delete env.HERMES_YAOYAO_TLS_KEY
  121. return utilityProcess.fork(join(root, 'server.mjs'), [], { cwd: root, env, stdio: 'pipe' })
  122. },
  123. })
  124. const { parseRunnerConfiguration } = await import(pathToFileURL(join(root,'runner-config.mjs')).href)
  125. runnerManager = new DesktopRunnerManager({home,validate:parseRunnerConfiguration,
  126. encrypt:value=>{if(!safeStorage.isEncryptionAvailable())throw new Error('系统钥匙串不可用,无法保存执行节点凭据');return safeStorage.encryptString(value)},
  127. decrypt:bytes=>safeStorage.decryptString(bytes),
  128. onState:message=>{log(`Runner: ${message}`);const item=Menu.getApplicationMenu()?.getMenuItemById('runner-status');if(item)item.label=message},
  129. fork:()=>utilityProcess.fork(join(root,'runner.mjs'),['--desktop-ipc'],{cwd:root,stdio:'pipe',env:{HOME:homedir(),PATH:[join(homedir(),'.local/bin'),join(homedir(),'.orbstack/bin'),'/opt/homebrew/bin','/usr/local/bin',process.env.PATH||'/usr/bin:/bin'].join(':'),NODE_ENV:'production',NODE_USE_ENV_PROXY:'0'}}),
  130. })
  131. async function runnerAction(action){try{await action()}catch(error){await dialog.showMessageBox(window,{type:'error',message:'执行节点操作未完成',detail:error.message})}}
  132. async function importRunner(){
  133. const selection=await dialog.showOpenDialog(window,{title:'导入执行节点配置',properties:['openFile'],filters:[{name:'Runner 配置',extensions:['json']}]})
  134. if(!selection.canceled&&selection.filePaths[0]&&!closing)await runnerAction(()=>runnerManager.importFile(selection.filePaths[0]))
  135. }
  136. Menu.setApplicationMenu(Menu.buildFromTemplate([
  137. { label: '夭夭', submenu: [{ role: 'about', label: '关于夭夭' }, { type: 'separator' },
  138. { label: '显示窗口', click: show }, { label: '在浏览器中打开', click: () => { if (manager.state.url) safeExternal(manager.state.url) } },
  139. { label: '查看服务日志', click: () => shell.showItemInFolder(logFile) }, { type: 'separator' },
  140. { role: 'hide', label: '隐藏夭夭' }, { role: 'hideOthers', label: '隐藏其他' }, { role: 'unhide', label: '显示全部' },
  141. { type: 'separator' }, { role: 'quit', label: '退出夭夭' }] },
  142. {label:'执行节点',submenu:[{id:'runner-status',label:'未配置执行节点',enabled:false},{type:'separator'},
  143. {id:'runner-import',label:'导入节点配置…',click:importRunner},
  144. {id:'runner-reconnect',label:'重新连接',click:()=>runnerAction(()=>runnerManager.start())},
  145. {id:'runner-forget',label:'断开并忘记配置',click:()=>runnerAction(()=>runnerManager.forget())}]},
  146. { role: 'editMenu', label: '编辑' }, { role: 'viewMenu', label: '显示' }, { role: 'windowMenu', label: '窗口' },
  147. ]))
  148. const icon = nativeImage.createFromPath(join(desktopRoot, 'icon.png')).resize({ width: 20, height: 20 })
  149. icon.setTemplateImage(true)
  150. tray = new Tray(icon)
  151. tray.setToolTip('夭夭')
  152. tray.setContextMenu(Menu.buildFromTemplate([{ label: '打开夭夭', click: show }, { label: '查看日志', click: () => shell.showItemInFolder(logFile) }, { type: 'separator' }, { label: '退出夭夭', click: () => app.quit() }]))
  153. tray.on('click', show)
  154. createWindow()
  155. powerMonitor.on('resume', () => { void manager.check() })
  156. timer = setInterval(() => { void manager.check() }, 10_000); timer.unref()
  157. await manager.start().catch(error => log(error.message))
  158. if(!closing)await runnerManager.start().catch(error=>{runnerManager.publish(error.message)})
  159. }).catch(error => { log(error.stack || error.message); quitting = true; app.quit() })
  160. }