main.mjs 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270
  1. import { app, BrowserWindow, ipcMain, Menu, Tray, nativeImage, shell, utilityProcess, powerMonitor, systemPreferences, dialog, safeStorage, net } from 'electron'
  2. import { appendFileSync, mkdirSync, readFileSync, realpathSync } 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. import { DesktopPreferences } from './preferences.mjs'
  9. import { DesktopCredentials } from './credentials.mjs'
  10. import { synchronizeLocalService, stopLocalService } from './service-sync.mjs'
  11. import { DesktopEnvironmentHost } from './environment-host.mjs'
  12. import { DesktopUpdateManager } from './update-manager.mjs'
  13. const desktopRoot = dirname(fileURLToPath(import.meta.url))
  14. const fixtureHome = process.env.HERMES_YAOYAO_DESKTOP_TEST_HOME
  15. // Fixture identity also isolates Chromium cookies, window state and the app lock.
  16. if (fixtureHome) app.setPath('userData', join(fixtureHome, 'desktop'))
  17. app.setName('夭夭')
  18. if (!app.requestSingleInstanceLock()) { app.quit() }
  19. else {
  20. let window, tray, manager, runnerManager, quitting = false, closing = false, timer
  21. let updateWindow, updater, environmentHost
  22. const updateURL = pathToFileURL(join(desktopRoot, 'update.html')).href
  23. const bootURL = pathToFileURL(join(desktopRoot, 'boot.html')).href
  24. const root = app.isPackaged ? join(process.resourcesPath, 'runtime') : join(app.getAppPath(), '.desktop-build')
  25. const logRoot = join(app.getPath('logs'), fixtureHome ? 'verification' : 'service')
  26. mkdirSync(logRoot, { recursive: true })
  27. const logFile = join(logRoot, 'server.log')
  28. const log = text => appendFileSync(logFile, `${new Date().toISOString()} ${text.trim()}\n`, { mode: 0o600 })
  29. const home = fixtureHome || process.env.HERMES_YAOYAO_HOME || join(homedir(), '.hermes-yaoyao')
  30. const preferences = new DesktopPreferences(home)
  31. let startHidden = false
  32. const port = Number(process.env.HERMES_YAOYAO_DESKTOP_PORT || 15300)
  33. if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('桌面服务端口无效')
  34. const trustedBoot = event => event.sender === window?.webContents && event.senderFrame?.url === bootURL
  35. ipcMain.handle('desktop:status', event => {
  36. if (!trustedBoot(event)) throw new Error('不允许此页面访问桌面服务')
  37. return { phase: manager?.state.phase || 'starting', message: manager?.state.message || '正在启动…' }
  38. })
  39. ipcMain.handle('desktop:retry', async event => {
  40. if (!trustedBoot(event)) throw new Error('不允许此页面启动桌面服务')
  41. manager.restarts = []
  42. try { await manager.start(); return true } catch { return false }
  43. })
  44. ipcMain.handle('desktop:logs', event => {
  45. if (!trustedBoot(event)) throw new Error('不允许此页面读取日志')
  46. shell.showItemInFolder(logFile)
  47. })
  48. const trustedUpdate = event => event.sender === updateWindow?.webContents
  49. && event.senderFrame === updateWindow.webContents.mainFrame && event.senderFrame.url === updateURL
  50. for (const action of ['state', 'check', 'download', 'cancel', 'open', 'folder', 'release']) {
  51. ipcMain.handle(`desktop-update:${action}`, async event => {
  52. if (!trustedUpdate(event) || closing || quitting) throw new Error('不允许此页面操作 App 更新')
  53. if (action === 'state') return updater.snapshot()
  54. if (action === 'check') return updater.check()
  55. if (action === 'download') return updater.download()
  56. if (action === 'cancel') { updater.cancel(); return updater.snapshot() }
  57. if (action === 'release') { safeExternal(updater.snapshot().releasePageUrl || 'https://github.com/Lsamien/hermes-yaoyao/releases'); return }
  58. const path = await updater.verifiedFile()
  59. if (closing || quitting || !trustedUpdate(event)) return
  60. if (action === 'folder') shell.showItemInFolder(path)
  61. else { const error = await shell.openPath(path); if (error) throw new Error(error) }
  62. })
  63. }
  64. function showUpdates() {
  65. if (closing || quitting) return
  66. if (updateWindow && !updateWindow.isDestroyed()) { updateWindow.show(); updateWindow.focus(); return }
  67. updateWindow = new BrowserWindow({ title: 'App 更新 · 夭夭', width: 640, height: 620, minWidth: 360, minHeight: 440, show: false,
  68. webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true, webSecurity: true,
  69. preload: join(desktopRoot, 'update-preload.cjs') } })
  70. updateWindow.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
  71. updateWindow.webContents.on('will-navigate', event => event.preventDefault())
  72. updateWindow.webContents.on('will-redirect', event => event.preventDefault())
  73. updateWindow.webContents.on('will-attach-webview', event => event.preventDefault())
  74. updateWindow.on('closed', () => { updater.cancel(); updateWindow = undefined })
  75. updateWindow.once('ready-to-show', () => updateWindow?.show())
  76. void updateWindow.loadURL(updateURL)
  77. }
  78. function show() {
  79. if (closing || quitting) return
  80. if (!window || window.isDestroyed()) createWindow()
  81. window.show(); window.focus()
  82. if (manager) void manager.reconnect().then(() => stateChanged(manager.state)).catch(error => log(error.message))
  83. }
  84. function safeExternal(url) {
  85. try { const parsed = new URL(url); if (['https:', 'http:', 'mailto:'].includes(parsed.protocol)) void shell.openExternal(url) }
  86. catch { /* No arbitrary file, shell or custom URL dispatch. */ }
  87. }
  88. function createWindow() {
  89. window = new BrowserWindow({ width: 1280, height: 860, minWidth: 720, minHeight: 560, show: false,
  90. title: '夭夭', backgroundColor: '#f7f7f7',
  91. webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true,
  92. webSecurity: true, preload: join(desktopRoot, 'preload.cjs') } })
  93. window.webContents.setWindowOpenHandler(({ url }) => {
  94. try {
  95. if (manager?.state.url && new URL(url).origin === manager.state.url) void window.loadURL(url)
  96. else safeExternal(url)
  97. } catch { /* Invalid navigation is denied. */ }
  98. return { action: 'deny' }
  99. })
  100. const guardNavigation = (event, url) => {
  101. if (url === bootURL || (manager?.state.url && new URL(url).origin === manager.state.url)) return
  102. event.preventDefault(); safeExternal(url)
  103. }
  104. window.webContents.on('will-navigate', guardNavigation)
  105. window.webContents.on('will-redirect', guardNavigation)
  106. window.webContents.on('will-attach-webview', event => event.preventDefault())
  107. const trustedService = (contents, url) => {
  108. try { return contents === window?.webContents && !!manager?.state.url && new URL(url).origin === manager.state.url }
  109. catch { return false }
  110. }
  111. window.webContents.session.setPermissionCheckHandler((contents, permission, origin) =>
  112. trustedService(contents, origin) && ['media', 'notifications', 'clipboard-sanitized-write'].includes(permission))
  113. window.webContents.session.setPermissionRequestHandler((contents, permission, callback, details) => {
  114. if (!trustedService(contents, details.requestingUrl)) { callback(false); return }
  115. if (permission === 'media' && details.mediaTypes?.length && details.mediaTypes.every(type => type === 'audio')) {
  116. void systemPreferences.askForMediaAccess('microphone').then(callback, () => callback(false))
  117. } else callback(['notifications', 'clipboard-sanitized-write', 'fullscreen'].includes(permission))
  118. })
  119. window.on('close', event => { if (!quitting) { event.preventDefault(); window.hide() } })
  120. window.once('ready-to-show', () => { if (!startHidden) window.show() })
  121. if (manager?.state.phase === 'ready') void window.loadURL(manager.state.url)
  122. else void window.loadFile(join(desktopRoot, 'boot.html'))
  123. }
  124. function stateChanged(state) {
  125. log(`${state.phase}: ${state.message}`)
  126. // Quit owns the window from this point on. A detached connection is not a
  127. // stopped server and must never navigate the closing window to boot.html.
  128. if (closing || quitting) return
  129. const serviceStatus=Menu.getApplicationMenu()?.getMenuItemById('desktop-service-status')
  130. if(serviceStatus)serviceStatus.label=state.phase==='ready'?'后台服务运行中':state.message
  131. const localVmMenu=Menu.getApplicationMenu()?.getMenuItemById('desktop-local-vm')
  132. if(localVmMenu)localVmMenu.enabled=state.phase==='ready'
  133. const notice = Menu.getApplicationMenu()?.getMenuItemById('desktop-sync-notice')
  134. if (notice) notice.visible = Boolean(state.updateNotice)
  135. const retrySync = Menu.getApplicationMenu()?.getMenuItemById('desktop-sync-retry')
  136. if (retrySync) retrySync.enabled = state.phase === 'ready' && Boolean(manager?.options.synchronize)
  137. tray?.setToolTip(`夭夭 · ${state.message}`)
  138. if (!window || window.isDestroyed()) return
  139. if (state.phase === 'ready') {
  140. const current = window.webContents.getURL()
  141. if (!current.startsWith(`${state.url}/`) && current !== state.url) void window.loadURL(state.url)
  142. } else if (window.webContents.getURL() !== bootURL) void window.loadFile(join(desktopRoot, 'boot.html'))
  143. }
  144. app.on('second-instance', show)
  145. app.on('activate', show)
  146. app.on('window-all-closed', () => {})
  147. function requestQuit(stopBackground = false) {
  148. if (closing || quitting) return
  149. closing = true; clearInterval(timer)
  150. updater?.cancel(); updateWindow?.close()
  151. window?.hide()
  152. // Native Cmd+Q can enter before-quit from Cocoa's termination callback.
  153. // Both cleanup and the final quit must run after that callback unwinds.
  154. setImmediate(() => { void (async()=>{
  155. try {
  156. await environmentHost?.close()
  157. await runnerManager?.stop()
  158. if (stopBackground) await manager?.stopBackground()
  159. else await manager?.stop()
  160. quitting = true
  161. setImmediate(() => app.quit())
  162. } catch(error) {
  163. log(error.message)
  164. window?.show()
  165. const choice=await dialog.showMessageBox(window,{type:'warning',message:stopBackground?'后台服务停止未完成':'App 退出清理未完成',detail:error.message,buttons:['返回应用','仅退出 App'],defaultId:0,cancelId:0})
  166. if(choice.response===1){quitting=true;setImmediate(()=>app.quit())}
  167. else {
  168. closing=false
  169. await manager?.reconnect().catch(error=>log(error.message))
  170. await runnerManager?.start().catch(error=>log(error.message))
  171. stateChanged(manager.state)
  172. timer=setInterval(()=>{void manager.check()},10000);timer.unref()
  173. show()
  174. }
  175. }
  176. })() })
  177. }
  178. app.on('before-quit', event => {
  179. if (quitting) return
  180. event.preventDefault()
  181. requestQuit()
  182. })
  183. app.whenReady().then(async () => {
  184. await preferences.load()
  185. startHidden = preferences.value.backgroundAtLogin && app.getLoginItemSettings().wasOpenedAtLogin
  186. const packageVersion = JSON.parse(readFileSync(join(root, 'release.json'), 'utf8')).webVersion
  187. const buildInfo=JSON.parse(readFileSync(join(root,'build-info.json'),'utf8'))
  188. const releases = await import(pathToFileURL(join(root, 'github-release.mjs')).href)
  189. updater = new DesktopUpdateManager({ version: packageVersion, cacheRoot: join(home, 'updates', 'desktop-downloads'),
  190. source: releases.DEFAULT_RELEASE_SOURCE, inspect: releases.inspectGitHubRelease, compare: releases.compareReleaseVersions,
  191. fetchImpl: (...args) => net.fetch(...args) })
  192. app.setAboutPanelOptions({applicationName:'夭夭',applicationVersion:packageVersion,version:`${String(buildInfo.commit).slice(0,12)}${buildInfo.dirty?' · 工作区快照':''}`})
  193. manager = new DesktopServiceManager({ home, port, version: packageVersion, log, onState: stateChanged,
  194. stopBackground: (app.isPackaged && !fixtureHome) || (fixtureHome && process.env.HERMES_YAOYAO_DESKTOP_TEST_SYNC === '1')
  195. ? onProgress => stopLocalService({home,port,root,fixture:Boolean(fixtureHome),onProgress}) : undefined,
  196. synchronize: (app.isPackaged && !fixtureHome) || (fixtureHome && process.env.HERMES_YAOYAO_DESKTOP_TEST_SYNC === '1')
  197. ? onProgress => synchronizeLocalService({ home, port, root, fixture: Boolean(fixtureHome), onProgress,
  198. environment: { HERMES_YAOYAO_UPSTREAM: process.env.HERMES_YAOYAO_UPSTREAM || 'http://127.0.0.1:9119', HERMES_YAOYAO_SUPERVISE_DASHBOARD: fixtureHome ? '0' : '1' } })
  199. : undefined,
  200. fork: ({ home, port }) => {
  201. const env = { ...process.env, NODE_ENV: 'production', NODE_USE_ENV_PROXY: '0',
  202. PATH: [join(homedir(), '.local/bin'), '/opt/homebrew/bin', '/usr/local/bin', process.env.PATH || '/usr/bin:/bin'].join(':'),
  203. HERMES_YAOYAO_DESKTOP: '1', HERMES_YAOYAO_HOME: home, HERMES_YAOYAO_HOST: '127.0.0.1',
  204. HERMES_YAOYAO_PORT: String(port), HERMES_YAOYAO_STATIC_DIR: join(root, 'ui'),
  205. HERMES_YAOYAO_SUPERVISE_DASHBOARD: fixtureHome ? '0' : '1',
  206. HERMES_YAOYAO_UPSTREAM: process.env.HERMES_YAOYAO_UPSTREAM || 'http://127.0.0.1:9119' }
  207. delete env.ELECTRON_RUN_AS_NODE
  208. delete env.HERMES_YAOYAO_TLS_CERT; delete env.HERMES_YAOYAO_TLS_KEY
  209. return utilityProcess.fork(join(root, 'server.mjs'), [], { cwd: root, env, stdio: 'pipe' })
  210. },
  211. })
  212. environmentHost=new DesktopEnvironmentHost({manager,root,dataRoot:app.getPath('userData')})
  213. const { parseRunnerConfiguration } = await import(pathToFileURL(join(root,'runner-config.mjs')).href)
  214. mkdirSync(home,{recursive:true,mode:0o700})
  215. const credentials=new DesktopCredentials({home:realpathSync(home),helper:join(root,app.isPackaged?'keychain-helper':'keychain-helper-dev'),legacyDecrypt:async bytes=>(await safeStorage.decryptStringAsync(bytes)).result})
  216. runnerManager = new DesktopRunnerManager({home,validate:parseRunnerConfiguration,
  217. encrypt:value=>credentials.encrypt(value),
  218. decrypt:bytes=>credentials.decrypt(bytes),
  219. onState:message=>{log(`Runner: ${message}`);const item=Menu.getApplicationMenu()?.getMenuItemById('runner-status');if(item)item.label=message},
  220. 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'}}),
  221. })
  222. async function runnerAction(action){try{await action()}catch(error){await dialog.showMessageBox(window,{type:'error',message:'执行节点操作未完成',detail:error.message})}}
  223. async function importRunner(){
  224. const selection=await dialog.showOpenDialog(window,{title:'导入执行节点配置',properties:['openFile'],filters:[{name:'Runner 配置',extensions:['json']}]})
  225. if(!selection.canceled&&selection.filePaths[0]&&!closing)await runnerAction(()=>runnerManager.importFile(selection.filePaths[0]))
  226. }
  227. Menu.setApplicationMenu(Menu.buildFromTemplate([
  228. { label: '夭夭', submenu: [{ role: 'about', label: '关于夭夭' }, { type: 'separator' },
  229. { id:'desktop-service-status',label:'正在连接后台服务…',enabled:false },
  230. { label: '显示窗口', click: show }, { label: '在浏览器中打开', click: () => { if (manager.state.url) safeExternal(manager.state.url) } },
  231. { id:'desktop-local-vm',label:'本地虚拟机设置…',enabled:false,click:()=>{if(manager.state.phase==='ready'){show();void window.webContents.executeJavaScript("window.dispatchEvent(new CustomEvent('yaoyao:local-vm-settings'))")}} },
  232. { label: '查看服务日志', click: () => shell.showItemInFolder(logFile) },
  233. { id:'desktop-sync-notice', label:'Web 同步待完成:服务忙碌', enabled:false, visible:false },
  234. { id:'desktop-sync-retry', label:'重试同步本机 Web…', enabled:false,
  235. click:async()=>{try{await manager.retrySynchronization()}catch(error){log(error.message)}} },
  236. { id:'desktop-login', label:'登录 macOS 时启动', type:'checkbox', enabled:app.isPackaged, checked:app.getLoginItemSettings().openAtLogin,
  237. click:async item=>{try{app.setLoginItemSettings({openAtLogin:item.checked});item.checked=app.getLoginItemSettings().openAtLogin}catch(error){item.checked=app.getLoginItemSettings().openAtLogin;await dialog.showMessageBox(window,{type:'error',message:'无法修改登录启动设置',detail:error.message})}} },
  238. { id:'desktop-background', label:'登录启动时仅驻留菜单栏', type:'checkbox', checked:preferences.value.backgroundAtLogin,
  239. click:async item=>{try{await preferences.setBackgroundAtLogin(item.checked)}catch(error){item.checked=preferences.value.backgroundAtLogin;await dialog.showMessageBox(window,{type:'error',message:error.message})}} },
  240. { id:'desktop-update-check', label:'检查 App 更新…', click:showUpdates },
  241. { id:'desktop-update-help', label:'App 更新与回退…', click:()=>dialog.showMessageBox(window,{type:'info',message:`夭夭 App ${packageVersion}`,detail:`App 构建:${String(buildInfo.commit).slice(0,12)}\n当前 Web:${manager.state.version||'尚未就绪'}${manager.state.build?.commit?' · '+manager.state.build.commit.slice(0,12):''}${manager.state.external?'(独立后台服务)':'(开发模式)'}\n\n通过“检查 App 更新”从 GitHub 下载并校验 DMG,手动拖入应用程序安装。开发签名包尚未公证,请遵循 macOS 的安装提示。\n\n安装后首次启动同步较旧的本机 Web,已有较新的 Web 保留。Web 可以独立升级,退出 App 保留独立后台服务。\n\n降级通过 Web 的回滚入口处理;数据库结构不兼容时不能直接降级。\n\n数据目录:${home}`,buttons:['知道了','打开数据目录']}).then(result=>{if(result.response===1)shell.showItemInFolder(home)}) },
  242. { type: 'separator' },
  243. { role: 'hide', label: '隐藏夭夭' }, { role: 'hideOthers', label: '隐藏其他' }, { role: 'unhide', label: '显示全部' },
  244. { type: 'separator' },
  245. {label:'撤销本机控制授权',click:()=>environmentHost.revoke()},
  246. { id:'desktop-quit',label:'退出夭夭(后台继续运行)',accelerator:'CommandOrControl+Q',click:()=>requestQuit() },
  247. { id:'desktop-stop-and-quit',label:'停止后台服务并退出',click:()=>requestQuit(true) }] },
  248. {label:'执行节点',submenu:[{id:'runner-status',label:'未配置执行节点',enabled:false},{type:'separator'},
  249. {id:'runner-import',label:'导入节点配置…',click:importRunner},
  250. {id:'runner-reconnect',label:'重新连接',click:()=>runnerAction(()=>runnerManager.start())},
  251. {id:'runner-forget',label:'断开并忘记配置',click:()=>runnerAction(()=>runnerManager.forget())}]},
  252. { role: 'editMenu', label: '编辑' }, { role: 'viewMenu', label: '显示' }, { role: 'windowMenu', label: '窗口' },
  253. ]))
  254. const icon = nativeImage.createFromPath(join(desktopRoot, 'icon.png')).resize({ width: 20, height: 20 })
  255. icon.setTemplateImage(true)
  256. tray = new Tray(icon)
  257. tray.setToolTip('夭夭')
  258. tray.setContextMenu(Menu.buildFromTemplate([{ label: '打开夭夭', click: show }, { label: '查看日志', click: () => shell.showItemInFolder(logFile) }, { type: 'separator' }, { label: '退出夭夭(后台继续运行)', click: () => requestQuit() }, { label: '停止后台服务并退出', click: () => requestQuit(true) }]))
  259. tray.on('click', show)
  260. createWindow()
  261. powerMonitor.on('resume', () => { if(!closing&&!quitting)void manager.check() })
  262. timer = setInterval(() => { void manager.check() }, 10_000); timer.unref()
  263. await manager.start().catch(error => log(error.message))
  264. if(!closing)environmentHost.start()
  265. if(!closing)await runnerManager.start().catch(error=>{runnerManager.publish(error.message)})
  266. }).catch(error => { log(error.stack || error.message); quitting = true; app.quit() })
  267. }