main.mjs 46 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706
  1. import { installUnreadBadge } from './unread-badge.mjs'
  2. import { app, BrowserWindow, ipcMain, Menu, Tray, nativeImage, shell, utilityProcess, powerMonitor, systemPreferences, dialog, safeStorage, net, session as electronSession } from 'electron'
  3. import { appendFileSync, mkdirSync, readFileSync, realpathSync } from 'node:fs'
  4. import { join, dirname } from 'node:path'
  5. import { homedir, hostname } from 'node:os'
  6. import { fileURLToPath, pathToFileURL } from 'node:url'
  7. import { DesktopServiceManager } from './service-manager.mjs'
  8. import { DesktopRunnerManager } from './runner-manager.mjs'
  9. import { DesktopPreferences } from './preferences.mjs'
  10. import { DesktopCredentials } from './credentials.mjs'
  11. import { synchronizeLocalService, stopLocalService, migrateLocalData } from './service-sync.mjs'
  12. import { resolveDataHome } from './data-home.mjs'
  13. import { DesktopEnvironmentHost, DesktopHostCore } from './environment-host.mjs'
  14. import { DesktopHostManager } from './host-manager.mjs'
  15. import { remoteRegistration, remoteSession, enrollDesktopHost, inspectServer, persistRemoteSession, sessionCookieDetails } from './remote-login.mjs'
  16. import { authorizeCurrentComputer } from './computer-authorization.mjs'
  17. import { DesktopLoginSessions } from './login-sessions.mjs'
  18. import { DesktopOnboarding } from './onboarding.mjs'
  19. import { DesktopUpdateManager } from './update-manager.mjs'
  20. import { DesktopAutoUpdateManager } from './auto-update-manager.mjs'
  21. import { createRequire } from 'node:module'
  22. import { installComputerViewer } from './computer-viewer.mjs'
  23. import { desktopPlatform, desktopMenu, loginItemOptions, clientOnlyRuntime } from './platform.mjs'
  24. const desktopRoot = dirname(fileURLToPath(import.meta.url))
  25. const fixtureHome = process.env.HERMES_YAOYAO_DESKTOP_TEST_HOME
  26. // Packaged: the runtime sits in Resources/runtime; dev: staged build roots.
  27. const clientOnly = app.isPackaged
  28. ? clientOnlyRuntime(join(process.resourcesPath, 'runtime'))
  29. : process.env.HERMES_YAOYAO_DESKTOP_CLIENT_ONLY === '0'
  30. ? false
  31. : clientOnlyRuntime(join(app.getAppPath(), '.desktop-build-client'))
  32. const platform = desktopPlatform(process.platform, { clientOnly })
  33. const loginOptions = loginItemOptions()
  34. // Fixture identity also isolates Chromium cookies, window state and the app lock.
  35. // The shell package name distinguishes the variants: the client keeps the
  36. // historical 夭夭 identity (userData, logs, single-instance lock) so upgrades
  37. // stay in place, while the full dual-mode build uses its own 夭夭完整版 home.
  38. const shellManifest = app.isPackaged ? join(desktopRoot, 'package.json')
  39. : join(app.getAppPath(), clientOnly ? '.desktop-build-client' : '.desktop-build', 'shell', 'package.json')
  40. const productName = JSON.parse(readFileSync(shellManifest, 'utf8')).productName || '夭夭'
  41. if (fixtureHome) app.setPath('userData', join(fixtureHome, 'desktop'))
  42. app.setName(productName)
  43. if (platform.windows) app.setAppUserModelId('cn.samien.yaoyao.desktop')
  44. if (!app.requestSingleInstanceLock()) { app.quit() }
  45. else {
  46. let window, tray, manager, runnerManager, quitting = false, closing = false, timer
  47. let updater, environmentHost, hostManager, onboarding
  48. let remoteMode = false, remoteURL = '', serverModeActive = false, switchingMode = false
  49. let recoveringService = false
  50. let resumeLocalAfterUpdate = false
  51. let checkRemoteAuthorization = async () => {}
  52. const serviceURL = () => remoteMode ? remoteURL : manager?.state.url
  53. // Use the same Chromium networking as server detection/navigation. Auth owns
  54. // its cookie jar: do not send or overwrite another account's browser cookies.
  55. const authFetch = (url, options) => net.fetch(url, { ...options, credentials: 'omit' })
  56. const serviceOrigins = () => {
  57. try { return remoteMode && remoteURL ? [new URL(remoteURL).origin] : manager?.state.url ? [new URL(manager.state.url).origin] : [] }
  58. catch { return [] }
  59. }
  60. const unreadBadge=installUnreadBadge({ipcMain,app,owner:()=>window,origins:serviceOrigins,url:serviceURL,quitting:()=>closing||quitting,fetch:(url,options)=>electronSession.defaultSession.fetch(url,options)})
  61. app.on('will-quit',()=>unreadBadge.stop())
  62. const closeComputerViewer = installComputerViewer({ owner: () => window, origin: () => serviceURL(),
  63. preload: join(desktopRoot, 'preload.cjs'), quitting: () => quitting })
  64. app.on('will-quit', closeComputerViewer)
  65. const bootURL = pathToFileURL(join(desktopRoot, 'boot.html')).href
  66. const inlineUpdateScript = readFileSync(join(desktopRoot, 'inline-update.js'), 'utf8')
  67. const inlineUpdateStyle = readFileSync(join(desktopRoot, 'inline-update.css'), 'utf8')
  68. const root = app.isPackaged ? join(process.resourcesPath, 'runtime')
  69. : join(app.getAppPath(), clientOnly ? '.desktop-build-client' : '.desktop-build')
  70. const logRoot = join(app.getPath('logs'), fixtureHome ? 'verification' : 'service')
  71. mkdirSync(logRoot, { recursive: true })
  72. const logFile = join(logRoot, 'server.log')
  73. const log = text => appendFileSync(logFile, `${new Date().toISOString()} ${text.trim()}\n`, { mode: 0o600 })
  74. const home = fixtureHome || resolveDataHome(process.env.YAOYAO_HOME || process.env.HERMES_YAOYAO_HOME)
  75. const preferences = new DesktopPreferences(home)
  76. let startHidden = false
  77. const port = Number(process.env.HERMES_YAOYAO_DESKTOP_PORT || 15300)
  78. if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('桌面服务端口无效')
  79. const trustedBoot = event => !closing && !quitting && event.sender === window?.webContents
  80. && event.senderFrame === window.webContents.mainFrame && event.senderFrame.url === bootURL
  81. ipcMain.handle('desktop:status', event => {
  82. if (!trustedBoot(event)) throw new Error('不允许此页面访问桌面服务')
  83. return onboarding.snapshot()
  84. })
  85. ipcMain.handle('desktop:retry', async event => {
  86. if (!trustedBoot(event)) throw new Error('不允许此页面启动桌面服务')
  87. if (!platform.server) throw new Error('此客户端不支持在本机运行服务器')
  88. manager.restarts = []
  89. return onboarding.prepare()
  90. })
  91. ipcMain.handle('desktop:logs', event => {
  92. if (!trustedBoot(event)) throw new Error('不允许此页面读取日志')
  93. shell.showItemInFolder(logFile)
  94. })
  95. ipcMain.handle('desktop:force-sync', async event => {
  96. if (!trustedBoot(event) || event.senderFrame !== window.webContents.mainFrame || closing || quitting || !manager?.canForceSynchronization)
  97. throw new Error('不允许此页面覆盖本机 Web')
  98. return onboarding.prepare({ force: true })
  99. })
  100. for (const action of ['select', 'prepare', 'submit']) {
  101. ipcMain.handle(`desktop:onboarding-${action}`, (event, input) => {
  102. if (!trustedBoot(event)) throw new Error('不允许此页面操作安装与登录')
  103. return onboarding[action](input)
  104. })
  105. }
  106. const trustedUpdate = event => {
  107. if (event.sender !== window?.webContents || event.senderFrame !== window.webContents.mainFrame) return false
  108. if (event.senderFrame.url === bootURL) return true
  109. try { return serviceOrigins().includes(new URL(event.senderFrame.url).origin) }
  110. catch { return false }
  111. }
  112. ipcMain.handle('desktop:updates', async event => {
  113. if (!trustedUpdate(event) || closing || quitting || !updater) throw new Error('不允许此页面检测 App 更新')
  114. await showUpdates()
  115. })
  116. for (const action of ['state', 'check', 'download', 'cancel', 'install', 'open', 'folder', 'release']) {
  117. ipcMain.handle(`desktop-update:${action}`, async event => {
  118. if (!trustedUpdate(event) || !updater) throw new Error('不允许此页面操作 App 更新')
  119. if (action === 'state') return updater.snapshot()
  120. if (closing || quitting) throw new Error('App 正在重启或退出')
  121. if (action === 'check') return updater.check()
  122. if (action === 'download') return updater.download()
  123. if (action === 'install') return updater.install()
  124. if (action === 'cancel') { updater.cancel(); return updater.snapshot() }
  125. if (action === 'release') { safeExternal(updater.snapshot().releasePageUrl || 'https://github.com/Lsamien/yaoyao/releases'); return }
  126. const path = await updater.verifiedFile()
  127. if (closing || quitting || !trustedUpdate(event)) return
  128. if (action === 'folder') shell.showItemInFolder(path)
  129. else { const error = await shell.openPath(path); if (error) throw new Error(error) }
  130. })
  131. }
  132. async function showUpdates() {
  133. if (closing || quitting || !updater) return
  134. if (!window || window.isDestroyed()) await createWindow()
  135. window.show(); window.focus()
  136. window?.webContents.send('desktop:update-requested')
  137. if (!updater.busy && updater.snapshot().phase !== 'ready') await updater.check()
  138. }
  139. function show() {
  140. if (closing || quitting) return
  141. if (!window || window.isDestroyed()) createWindow()
  142. window.show(); window.focus()
  143. if (serverModeActive && manager) void manager.reconnect().then(() => stateChanged(manager.state)).catch(error => log(error.message))
  144. }
  145. function safeExternal(url) {
  146. try { const parsed = new URL(url); if (['https:', 'http:', 'mailto:'].includes(parsed.protocol)) void shell.openExternal(url) }
  147. catch { /* No arbitrary file, shell or custom URL dispatch. */ }
  148. }
  149. function createWindow() {
  150. window = new BrowserWindow({ width: 1280, height: 860, minWidth: 720, minHeight: 560, show: false,
  151. title: '夭夭', backgroundColor: '#f7f7f7',
  152. webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true,
  153. webSecurity: true, preload: join(desktopRoot, 'preload.cjs') } })
  154. window.webContents.setWindowOpenHandler(({ url }) => {
  155. try {
  156. if (serviceOrigins().includes(new URL(url).origin)) void window.loadURL(url)
  157. else safeExternal(url)
  158. } catch { /* Invalid navigation is denied. */ }
  159. return { action: 'deny' }
  160. })
  161. const guardNavigation = (event, url) => {
  162. try { if (url === bootURL || serviceOrigins().includes(new URL(url).origin)) return }
  163. catch { /* Invalid navigation is denied. */ }
  164. event.preventDefault(); safeExternal(url)
  165. }
  166. window.webContents.on('did-start-navigation',(_event,_url,_inPlace,isMainFrame)=>{if(isMainFrame)unreadBadge.clear()})
  167. window.webContents.on('will-navigate', guardNavigation)
  168. window.webContents.on('will-redirect', guardNavigation)
  169. window.webContents.on('will-attach-webview', event => event.preventDefault())
  170. window.webContents.on('did-finish-load', () => {
  171. // Older remote Web builds have no inline updater component. The local
  172. // renderer supplies it until a current workspace claims the entry.
  173. const contents = window.webContents
  174. try {
  175. if (!serviceOrigins().includes(new URL(contents.getURL()).origin)) return
  176. void contents.insertCSS(inlineUpdateStyle).then(() => contents.executeJavaScript(inlineUpdateScript)).catch(error => log(error.message))
  177. } catch { /* The local startup page already loads the widget itself. */ }
  178. })
  179. const trustedService = (contents, url) => {
  180. try { return contents === window?.webContents && serviceOrigins().includes(new URL(url).origin) }
  181. catch { return false }
  182. }
  183. window.webContents.session.setPermissionCheckHandler((contents, permission, origin) =>
  184. closeComputerViewer.owns(contents) ? ['fullscreen', 'clipboard-sanitized-write'].includes(permission)
  185. : trustedService(contents, origin) && ['media', 'notifications', 'clipboard-sanitized-write'].includes(permission))
  186. window.webContents.session.setPermissionRequestHandler((contents, permission, callback, details) => {
  187. if (closeComputerViewer.owns(contents)) { callback(['fullscreen', 'clipboard-sanitized-write'].includes(permission)); return }
  188. if (!trustedService(contents, details.requestingUrl)) { callback(false); return }
  189. if (permission === 'media' && details.mediaTypes?.length && details.mediaTypes.every(type => type === 'audio')) {
  190. if (platform.windows) callback(systemPreferences.getMediaAccessStatus('microphone') !== 'denied')
  191. else void systemPreferences.askForMediaAccess('microphone').then(callback, () => callback(false))
  192. } else callback(['notifications', 'clipboard-sanitized-write', 'fullscreen'].includes(permission))
  193. })
  194. window.on('close', event => { if (!quitting) { event.preventDefault(); window.hide() } })
  195. window.once('ready-to-show', () => { if (!startHidden) window.show() })
  196. if (onboarding?.state.active) return window.loadURL(bootURL)
  197. if (remoteMode && remoteURL) return window.loadURL(remoteURL)
  198. if (manager?.state.phase === 'ready') return window.loadURL(manager.state.url)
  199. return window.loadFile(join(desktopRoot, 'boot.html'))
  200. }
  201. function stateChanged(state) {
  202. log(`${state.phase}: ${state.message}`)
  203. onboarding?.serviceChanged(state)
  204. // Quit owns the window from this point on. A detached connection is not a
  205. // stopped server and must never navigate the closing window to boot.html.
  206. if (closing || quitting || remoteMode) return
  207. const serviceStatus=Menu.getApplicationMenu()?.getMenuItemById('desktop-service-status')
  208. if(serviceStatus)serviceStatus.label=state.phase==='ready'?'后台服务运行中':state.message
  209. const localVmMenu=Menu.getApplicationMenu()?.getMenuItemById('desktop-local-vm')
  210. if(localVmMenu)localVmMenu.enabled=state.phase==='ready'
  211. const notice = Menu.getApplicationMenu()?.getMenuItemById('desktop-sync-notice')
  212. if (notice) notice.visible = Boolean(state.updateNotice)
  213. const retrySync = Menu.getApplicationMenu()?.getMenuItemById('desktop-sync-retry')
  214. if (retrySync) retrySync.enabled = state.phase === 'ready' && Boolean(manager?.options.synchronize)
  215. const forceSync = Menu.getApplicationMenu()?.getMenuItemById('desktop-sync-force')
  216. if (forceSync) forceSync.enabled = !closing && !quitting && manager?.canForceSynchronization === true
  217. tray?.setToolTip(`夭夭 · ${state.message}`)
  218. if (!window || window.isDestroyed()) return
  219. if (onboarding?.state.active) {
  220. if (recoveringService && state.phase === 'ready') {
  221. recoveringService = false
  222. if (!onboarding.busy) void onboarding.prepare({ autoEnter: true }).catch(error => log(error.message))
  223. }
  224. return
  225. }
  226. if (state.phase === 'ready') {
  227. const current = window.webContents.getURL()
  228. if (!current.startsWith(`${state.url}/`) && current !== state.url) void window.loadURL(state.url)
  229. } else if (window.webContents.getURL() !== bootURL) {
  230. recoveringService = true
  231. onboarding.open({ mode: 'local', remember: preferences.value.startupChoice === 'local', restoring: true })
  232. onboarding.serviceChanged(state)
  233. void window.loadURL(bootURL)
  234. }
  235. }
  236. app.on('second-instance', show)
  237. app.on('activate', show)
  238. app.on('window-all-closed', () => {})
  239. function requestQuit(stopBackground = false) {
  240. if (closing || quitting) return
  241. const resumeLocal = serverModeActive && manager?.activationRequested === true
  242. closing = true; unreadBadge.clear(); clearInterval(timer)
  243. updater?.stopChecking?.(); updater?.cancel()
  244. window?.hide()
  245. // Native Cmd+Q can enter before-quit from Cocoa's termination callback.
  246. // Both cleanup and the final quit must run after that callback unwinds.
  247. setImmediate(() => { void (async()=>{
  248. try {
  249. await environmentHost?.close()
  250. await hostManager?.stop()
  251. await runnerManager?.stop()
  252. if (stopBackground) await manager?.stopBackground()
  253. else await manager?.stop()
  254. quitting = true
  255. setImmediate(() => app.quit())
  256. } catch(error) {
  257. log(error.message)
  258. window?.show()
  259. const choice=await dialog.showMessageBox(window,{type:'warning',message:stopBackground?'后台服务停止未完成':'App 退出清理未完成',detail:error.message,buttons:['返回应用','仅退出 App'],defaultId:0,cancelId:0})
  260. if(choice.response===1){quitting=true;setImmediate(()=>app.quit())}
  261. else {
  262. closing=false
  263. if(remoteMode)await hostManager?.start().catch(error=>log(error.message))
  264. else if(serverModeActive){
  265. await manager?.reconnect().catch(error=>log(error.message))
  266. if(resumeLocal){
  267. await manager?.activate().catch(error=>log(error.message))
  268. environmentHost?.start()
  269. await runnerManager?.start().catch(error=>log(error.message))
  270. }
  271. }
  272. if (manager) stateChanged(manager.state)
  273. timer=setInterval(()=>{if(serverModeActive)void manager.check();else void checkRemoteAuthorization()},10000);timer.unref()
  274. updater?.startChecking?.()
  275. show()
  276. }
  277. }
  278. })() })
  279. }
  280. async function prepareUpdateRestart() {
  281. if (closing || quitting) throw new Error('App 正在退出,请稍后重试')
  282. resumeLocalAfterUpdate = serverModeActive && manager?.activationRequested === true
  283. closing = true; unreadBadge.clear(); clearInterval(timer)
  284. await environmentHost?.close()
  285. await hostManager?.stop()
  286. await runnerManager?.stop()
  287. await manager?.stop()
  288. // Cleanup has completed before Squirrel begins quitting. Releasing the
  289. // single-instance lock lets the newly installed app start immediately.
  290. quitting = true
  291. app.releaseSingleInstanceLock()
  292. }
  293. async function recoverUpdateRestart() {
  294. quitting = false; closing = false
  295. if (!app.requestSingleInstanceLock()) { quitting = true; app.quit(); return }
  296. if (remoteMode) await hostManager?.start().catch(error => log(error.message))
  297. else if (serverModeActive) {
  298. await manager?.reconnect().catch(error => log(error.message))
  299. if (resumeLocalAfterUpdate) {
  300. await manager?.activate().catch(error => log(error.message))
  301. environmentHost?.start()
  302. await runnerManager?.start().catch(error => log(error.message))
  303. }
  304. }
  305. clearInterval(timer)
  306. timer = setInterval(() => { if (serverModeActive) void manager?.check(); else void checkRemoteAuthorization() }, 10000); timer.unref()
  307. window?.show()
  308. }
  309. app.on('before-quit', event => {
  310. if (quitting) return
  311. event.preventDefault()
  312. requestQuit()
  313. })
  314. app.whenReady().then(async () => {
  315. await preferences.load()
  316. startHidden = preferences.value.backgroundAtLogin && (platform.windows
  317. ? process.argv.includes('--yaoyao-login') : app.getLoginItemSettings().wasOpenedAtLogin)
  318. const packageVersion = JSON.parse(readFileSync(join(root, 'release.json'), 'utf8')).webVersion
  319. const buildInfo=JSON.parse(readFileSync(join(root,'build-info.json'),'utf8'))
  320. const releases = await import(pathToFileURL(join(root, 'github-release.mjs')).href)
  321. if ((app.isPackaged && !fixtureHome) || (fixtureHome && process.env.HERMES_YAOYAO_DESKTOP_TEST_AUTO_UPDATE === '1')) {
  322. const updateModule = createRequire(import.meta.url)(join(root, 'electron-updater.cjs'))
  323. const autoUpdater = platform.windows ? new updateModule.NsisUpdater() : updateModule.autoUpdater
  324. autoUpdater.logger = Object.fromEntries(['info', 'warn', 'error', 'debug'].map(level => [level, (...values) => log(`[更新:${level}] ${values.join(' ')}`)]))
  325. updater = new DesktopAutoUpdateManager({ driver: autoUpdater, version: packageVersion, platform: platform.platform,
  326. prepareInstall: prepareUpdateRestart, recoverInstall: recoverUpdateRestart })
  327. updater.startChecking()
  328. } else {
  329. updater = new DesktopUpdateManager({ version: packageVersion, cacheRoot: join(home, 'updates', 'desktop-downloads'),
  330. source: releases.DEFAULT_RELEASE_SOURCE, inspect: releases.inspectGitHubRelease, compare: releases.compareReleaseVersions,
  331. fetchImpl: (...args) => net.fetch(...args) })
  332. }
  333. app.setAboutPanelOptions({applicationName:'夭夭',applicationVersion:packageVersion,version:`${String(buildInfo.commit).slice(0,12)}${buildInfo.dirty?' · 工作区快照':''}`})
  334. const credentials=new DesktopCredentials({home,platform:platform.platform,safeStorage,helper:join(root,app.isPackaged?'keychain-helper':'keychain-helper-dev'),legacyDecrypt:async bytes=>(await safeStorage.decryptStringAsync(bytes)).result})
  335. const loginSessions = new DesktopLoginSessions({ home, encrypt: value => credentials.encrypt(value), decrypt: value => credentials.decrypt(value) })
  336. async function installSessionCookies(server, cookies) {
  337. for (const cookie of cookies) await electronSession.defaultSession.cookies.set({ url: server, ...cookie,
  338. httpOnly: true, secure: cookie.secure || server.startsWith('https://') })
  339. await electronSession.defaultSession.cookies.flushStore()
  340. }
  341. // Serialize vault + Chromium changes so an in-flight restore cannot undo logout.
  342. let loginOperation = Promise.resolve()
  343. function synchronizeLogin(run) {
  344. const next = loginOperation.then(run)
  345. loginOperation = next.catch(() => {})
  346. return next
  347. }
  348. async function clearLogin(server) {
  349. await loginSessions.forget(server)
  350. await electronSession.defaultSession.cookies.remove(server, 'hermes_yaoyao_session')
  351. await electronSession.defaultSession.cookies.flushStore()
  352. }
  353. function forgetLogin(server) { return synchronizeLogin(() => clearLogin(server)) }
  354. async function inspectRemote(server) {
  355. return synchronizeLogin(async () => {
  356. const saved = await loginSessions.read(server)
  357. const info = await inspectServer(server, saved
  358. ? (url, options) => authFetch(url, { ...options, headers: { ...options.headers, cookie: `${saved.cookie.name}=${saved.cookie.value}` } })
  359. : (...args) => net.fetch(...args))
  360. if (!info.authenticated) {
  361. if (saved) await clearLogin(server)
  362. return info
  363. }
  364. if (saved) {
  365. await installSessionCookies(server, [{ ...saved.cookie, expirationDate: Date.now() / 1000 + 400 * 86400 }])
  366. } else {
  367. const cookies = await electronSession.defaultSession.cookies.get({ url: server })
  368. const persistent = await persistRemoteSession(server, info, cookies, authFetch)
  369. await loginSessions.save(server, persistent)
  370. await installSessionCookies(server, persistent)
  371. }
  372. return info
  373. })
  374. }
  375. if (platform.server) {
  376. manager = new DesktopServiceManager({ home, port, version: packageVersion, log, onState: stateChanged,
  377. prepareHome: !fixtureHome ? onProgress => migrateLocalData({ home, port, root, onProgress }) : undefined,
  378. stopBackground: (app.isPackaged && !fixtureHome) || (fixtureHome && process.env.HERMES_YAOYAO_DESKTOP_TEST_SYNC === '1')
  379. ? onProgress => stopLocalService({home,port,root,fixture:Boolean(fixtureHome),onProgress}) : undefined,
  380. synchronize: (app.isPackaged && !fixtureHome) || (fixtureHome && process.env.HERMES_YAOYAO_DESKTOP_TEST_SYNC === '1')
  381. ? (onProgress, { force = false } = {}) => synchronizeLocalService({ home, port, root, fixture: Boolean(fixtureHome), onProgress, force,
  382. environment: { HERMES_YAOYAO_UPSTREAM: process.env.HERMES_YAOYAO_UPSTREAM || 'http://127.0.0.1:9119', HERMES_YAOYAO_SUPERVISE_DASHBOARD: fixtureHome ? '0' : '1', HERMES_YAOYAO_DEFER_HERMES: '1' } })
  383. : undefined,
  384. fork: ({ home, port }) => {
  385. const env = { ...process.env, NODE_ENV: 'production', NODE_USE_ENV_PROXY: '0',
  386. PATH: [join(homedir(), '.local/bin'), '/opt/homebrew/bin', '/usr/local/bin', process.env.PATH || '/usr/bin:/bin'].join(':'),
  387. HERMES_YAOYAO_DESKTOP: '1', HERMES_YAOYAO_HOME: home, HERMES_YAOYAO_HOST: '127.0.0.1',
  388. HERMES_YAOYAO_PORT: String(port), HERMES_YAOYAO_STATIC_DIR: join(root, 'ui'),
  389. HERMES_YAOYAO_SUPERVISE_DASHBOARD: fixtureHome ? '0' : '1',
  390. HERMES_YAOYAO_DEFER_HERMES: '1',
  391. HERMES_YAOYAO_UPSTREAM: process.env.HERMES_YAOYAO_UPSTREAM || 'http://127.0.0.1:9119' }
  392. delete env.ELECTRON_RUN_AS_NODE
  393. delete env.HERMES_YAOYAO_TLS_CERT; delete env.HERMES_YAOYAO_TLS_KEY
  394. return utilityProcess.fork(join(root, 'server.mjs'), [], { cwd: root, env, stdio: 'pipe' })
  395. },
  396. })
  397. environmentHost=new DesktopEnvironmentHost({manager,root,dataRoot:app.getPath('userData')})
  398. const { parseRunnerConfiguration } = await import(pathToFileURL(join(root,'runner-config.mjs')).href)
  399. runnerManager = new DesktopRunnerManager({home,validate:parseRunnerConfiguration,
  400. encrypt:value=>credentials.encrypt(value),
  401. decrypt:bytes=>credentials.decrypt(bytes),
  402. onState:message=>{log(`Runner: ${message}`);const item=Menu.getApplicationMenu()?.getMenuItemById('runner-status');if(item)item.label=message},
  403. 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'}}),
  404. })
  405. }
  406. async function runnerAction(action){try{await action()}catch(error){await dialog.showMessageBox(window,{type:'error',message:'执行节点操作未完成',detail:error.message})}}
  407. hostManager=new DesktopHostManager({home,root,dataRoot:app.getPath('userData'),
  408. // Device polling must share the Chromium transport used to log in to LAN servers.
  409. fetchImpl:authFetch,
  410. createCore:({root,dataRoot})=>new DesktopHostCore({root,dataRoot}),
  411. encrypt:value=>credentials.encrypt(value),
  412. decrypt:bytes=>credentials.decrypt(bytes),
  413. onState:message=>{log(`电脑: ${message}`);const item=Menu.getApplicationMenu()?.getMenuItemById('desktop-host-status');if(item)item.label=message}})
  414. ipcMain.handle('desktop:device-host', async event => {
  415. try {
  416. const trusted = event.sender === window?.webContents && event.senderFrame === event.sender.mainFrame
  417. && serviceOrigins().includes(new URL(event.senderFrame.url).origin)
  418. if (!trusted || closing || quitting) throw new Error('不允许此页面读取设备身份')
  419. } catch (error) {
  420. if (error.message === '不允许此页面读取设备身份') throw error
  421. throw new Error('不允许此页面读取设备身份')
  422. }
  423. if (remoteMode) {
  424. const enrolled = await hostManager.read().catch(() => undefined)
  425. return { deviceHost: enrolled && new URL(enrolled.serverURL).origin === new URL(remoteURL).origin ? enrolled.hostId : null }
  426. }
  427. return { deviceHost: 'local' }
  428. })
  429. async function hostAction(action){try{await action()}catch(error){await dialog.showMessageBox(window,{type:'error',message:'电脑操作未完成',detail:error.message})}}
  430. const appDialog = options => window && !window.isDestroyed() ? dialog.showMessageBox(window, options) : dialog.showMessageBox(options)
  431. let authorizedOnboardingServer = ''
  432. async function authorizeComputer(server,session){
  433. const previous=await hostManager.read().catch(()=>undefined)
  434. const previousHostId=previous&&new URL(previous.serverURL).origin===new URL(server).origin?previous.hostId:undefined
  435. const registration=await enrollDesktopHost(session,{name:hostname(),installId:await hostManager.installIdentity(),previousHostId},authFetch)
  436. await hostManager.adopt({protocol:1,serverURL:server,hostId:registration.host.id,token:registration.token,...(server.startsWith('http://')?{allowInsecureLan:true}:{})})
  437. return registration
  438. }
  439. function requireModePage(event){
  440. let trusted=false
  441. try{trusted=event.sender===window?.webContents&&event.senderFrame===window.webContents.mainFrame
  442. &&serviceOrigins().includes(new URL(event.senderFrame.url).origin)}catch{}
  443. if(!trusted||closing||quitting)throw new Error('不允许此页面切换桌面运行模式')
  444. }
  445. ipcMain.handle('desktop:mode-state',event=>{
  446. requireModePage(event)
  447. return {mode:remoteMode||!platform.server?'client':'server',serverURL:serviceURL()||'',switching:switchingMode,
  448. platform:platform.platform,supportedModes:platform.supportedModes}
  449. })
  450. ipcMain.handle('desktop:mode-switch',async(event,mode)=>{
  451. requireModePage(event)
  452. if(mode!=='client'&&mode!=='server')throw new Error('运行模式无效')
  453. return switchMode(mode==='client'?'remote':'local')
  454. })
  455. ipcMain.handle('desktop:remote-login',event=>{
  456. requireModePage(event)
  457. if(switchingMode)throw new Error('正在切换运行模式,请稍候')
  458. return openRemoteLogin()
  459. })
  460. ipcMain.handle('desktop:device-authorize', async (event, scope) => {
  461. requireModePage(event)
  462. if (switchingMode) throw new Error('正在切换运行模式,请稍候')
  463. const origin = new URL(event.senderFrame.url).origin
  464. const core = remoteMode ? hostManager?.core : environmentHost?.core
  465. const isCurrent = () => !closing && !quitting && !switchingMode && core === (remoteMode ? hostManager?.core : environmentHost?.core)
  466. && new URL(event.senderFrame.url).origin === origin
  467. && (!remoteMode || hostManager?.config?.serverURL === remoteURL)
  468. const cookies = await electronSession.defaultSession.cookies.get({ url: origin })
  469. if (!isCurrent()) throw new Error('当前连接已改变,请重新打开 Bot 设置')
  470. return authorizeCurrentComputer({ scope, core, origin, cookies, fetchImpl: authFetch, isCurrent })
  471. })
  472. ipcMain.handle('desktop:computer-authorize',async(event,csrfToken)=>{
  473. requireModePage(event)
  474. if(!remoteMode||!remoteURL)return {registered:false}
  475. try{
  476. const token=String(csrfToken??'').trim()
  477. if(!token)throw new Error('登录状态缺少安全令牌,请重新登录')
  478. const cookies=await electronSession.defaultSession.cookies.get({url:remoteURL})
  479. const registration=await authorizeComputer(remoteURL,{origin:remoteURL,csrfToken:token,cookies:cookies.map(cookie=>[cookie.name,cookie.value])})
  480. return {registered:true,hostId:registration.host.id}
  481. }catch(error){
  482. const message=error instanceof Error?error.message:'电脑授权恢复失败'
  483. hostManager.publish(`电脑授权未恢复:${message}`)
  484. return {registered:false}
  485. }
  486. })
  487. async function importDesktopHost(){
  488. const selection=window&&!window.isDestroyed()
  489. ?await dialog.showOpenDialog(window,{title:'导入电脑配置',properties:['openFile'],filters:[{name:'电脑配置',extensions:['json']}]})
  490. :await dialog.showOpenDialog({title:'导入电脑配置',properties:['openFile'],filters:[{name:'电脑配置',extensions:['json']}]})
  491. if(!selection.canceled&&selection.filePaths[0]&&!closing)await hostAction(()=>hostManager.importFile(selection.filePaths[0]))
  492. }
  493. if (!preferences.value.remoteServer) {
  494. const enrolled = await hostManager.read().catch(() => undefined)
  495. if (enrolled?.serverURL) await preferences.setRemoteServer(enrolled.serverURL)
  496. }
  497. onboarding = new DesktopOnboarding({
  498. platform: platform.platform, supportedModes: platform.supportedModes,
  499. remoteServer: () => preferences.value.remoteServer,
  500. inspect: inspectRemote,
  501. localSession: async server => {
  502. const session = await manager.adminSession()
  503. await installSessionCookies(server, sessionCookieDetails(session.setCookies))
  504. return { authenticated: true, user: session.user }
  505. },
  506. prepareLocal: async ({ force }) => {
  507. if (!platform.server) throw new Error('此客户端不支持在本机运行服务器')
  508. serverModeActive = true; remoteMode = false; remoteURL = ''
  509. await hostManager.stop()
  510. manager.restarts = []
  511. if (force) await manager.retrySynchronization({ force: true })
  512. else await manager.start()
  513. if (manager.state.phase !== 'ready') throw new Error(manager.state.message)
  514. return manager.state.url
  515. },
  516. register: ({ serverURL, username, password }) => remoteRegistration(serverURL, { username, password }, authFetch),
  517. authenticate: async ({ mode, serverURL, setup, username, password }) => {
  518. authorizedOnboardingServer = ''
  519. const auth = await remoteSession(serverURL, { setup, username, password }, authFetch)
  520. if (mode === 'remote') await loginSessions.save(serverURL, auth.cookieDetails)
  521. await installSessionCookies(serverURL, auth.cookieDetails)
  522. let warning
  523. if (mode === 'remote' && auth.user.role === 'admin') {
  524. try { await authorizeComputer(serverURL, auth); authorizedOnboardingServer = serverURL }
  525. catch (error) { warning = `账号已登录,但电脑授权未恢复:${error.message}。可先进入夭夭,稍后在“电脑”菜单重新授权。` }
  526. }
  527. return { username: auth.user.username, warning }
  528. },
  529. activate: async ({ mode, serverURL, remember }) => {
  530. if (closing || quitting) throw new Error('App 正在退出')
  531. const alreadyAuthorized = authorizedOnboardingServer === serverURL
  532. authorizedOnboardingServer = ''
  533. if (mode === 'remote') {
  534. // Detach only; an independently installed local service keeps running.
  535. serverModeActive = false
  536. await environmentHost?.close()
  537. await runnerManager?.stop()
  538. await manager?.stop()
  539. remoteMode = true; remoteURL = serverURL
  540. await preferences.setRemoteServer(serverURL)
  541. if (!alreadyAuthorized) {
  542. // A remembered browser session skips password authentication. Restore
  543. // its device authorization only on entry, never during detection.
  544. try {
  545. const current = await inspectServer(serverURL, (...args) => net.fetch(...args))
  546. if (current.authenticated && current.user?.role === 'admin') {
  547. const cookies = await electronSession.defaultSession.cookies.get({ url: serverURL })
  548. await authorizeComputer(serverURL, { origin: serverURL, csrfToken: current.csrfToken,
  549. cookies: cookies.map(cookie => [cookie.name, cookie.value]) })
  550. }
  551. } catch (error) { hostManager.publish(`电脑授权未恢复:${error.message}`) }
  552. }
  553. const enrolled = await hostManager.read().catch(error => { log(error.message); return undefined })
  554. if (enrolled && new URL(enrolled.serverURL).origin === new URL(serverURL).origin)
  555. await hostManager.start().catch(error => hostManager.publish(error.message))
  556. } else {
  557. await manager.activate()
  558. if (closing || quitting) throw new Error('App 正在退出')
  559. environmentHost.start()
  560. await runnerManager.start().catch(error => runnerManager.publish(error.message))
  561. }
  562. await preferences.setStartupChoice(remember ? mode : 'ask')
  563. syncModeMenu()
  564. const status = Menu.getApplicationMenu()?.getMenuItemById('desktop-service-status')
  565. if (status) status.label = mode === 'remote' ? `已登录远程 · ${new URL(serverURL).host}` : '后台服务运行中'
  566. tray?.setToolTip(mode === 'remote' ? `夭夭 · 远程 ${new URL(serverURL).host}` : '夭夭 · 本机运行')
  567. },
  568. navigate: url => {
  569. if (closing || quitting) throw new Error('App 正在退出')
  570. return window.loadURL(url)
  571. },
  572. })
  573. async function openOnboarding(mode, { autoPrepare = false, remember = preferences.value.startupChoice !== 'ask', forceLogin = false } = {}) {
  574. if (closing || quitting) throw new Error('App 正在退出')
  575. const prepare = autoPrepare && (mode === 'local' || Boolean(preferences.value.remoteServer))
  576. onboarding.open({ mode, serverURL: preferences.value.remoteServer, remember, forceLogin, restoring: prepare && !forceLogin })
  577. if (!window || window.isDestroyed()) await createWindow()
  578. else if (window.webContents.getURL() !== bootURL) await window.loadURL(bootURL)
  579. window.show(); window.focus()
  580. if (prepare)
  581. await onboarding.prepare({ autoEnter: true })
  582. return onboarding.snapshot()
  583. }
  584. function openRemoteLogin() {
  585. return openOnboarding('remote', { forceLogin: true })
  586. }
  587. ipcMain.handle('desktop:open-login', event => {
  588. requireModePage(event)
  589. // A session expiry returns to the same main-page flow for the active server.
  590. return openOnboarding(remoteMode ? 'remote' : 'local', { autoPrepare: true })
  591. })
  592. ipcMain.handle('desktop:forget-login', async event => {
  593. requireModePage(event)
  594. if (remoteMode && remoteURL) {
  595. await forgetLogin(remoteURL)
  596. await hostManager.stop()
  597. }
  598. })
  599. let checkingAuthorization = false
  600. checkRemoteAuthorization = async () => {
  601. if (checkingAuthorization || closing || quitting || !remoteMode || onboarding.state.active) return
  602. checkingAuthorization = true
  603. const server = remoteURL
  604. try {
  605. const info = await inspectServer(server, (...args) => net.fetch(...args))
  606. if (!info.authenticated && remoteURL === server && !onboarding.state.active && !closing && !quitting) {
  607. // Chromium may expire its copy; the native authorization remains authoritative.
  608. if ((await inspectRemote(server)).authenticated) return
  609. await forgetLogin(server)
  610. await hostManager.stop()
  611. await openOnboarding('remote', { autoPrepare: true })
  612. }
  613. } catch { /* Network loss does not revoke a saved login. */ }
  614. finally { checkingAuthorization = false }
  615. }
  616. function syncModeMenu(){
  617. const menu=Menu.getApplicationMenu()
  618. const useRemote=menu?.getMenuItemById('desktop-host-use-remote'),useLocal=menu?.getMenuItemById('desktop-host-use-local'),ask=menu?.getMenuItemById('desktop-host-ask')
  619. if(useRemote)useRemote.enabled=!switchingMode&&!remoteMode
  620. if(useLocal)useLocal.enabled=!switchingMode&&!serverModeActive
  621. const status=menu?.getMenuItemById('desktop-mode-status')
  622. if(status)status.label=switchingMode?'正在切换运行模式…':remoteMode?'当前:客户端模式':serverModeActive?'当前:服务器模式':'请选择运行模式'
  623. if(ask)ask.checked=preferences.value.startupChoice==='ask'
  624. }
  625. async function switchMode(mode) {
  626. if (mode === 'local' && !platform.server) return { ok: false, error: '此客户端不支持在本机运行服务器' }
  627. if (closing || quitting) return { ok: false, error: 'App 正在退出' }
  628. if (switchingMode || onboarding.busy) return { ok: false, error: '当前步骤尚未完成,请稍候' }
  629. switchingMode = true; syncModeMenu()
  630. try {
  631. const state = await openOnboarding(mode, { autoPrepare: true, remember: true })
  632. return { ok: true, pendingLogin: state.active }
  633. } catch (error) {
  634. return { ok: false, error: error.message }
  635. } finally { switchingMode = false; syncModeMenu() }
  636. }
  637. async function importRunner(){
  638. const selection=await dialog.showOpenDialog(window,{title:'导入执行节点配置',properties:['openFile'],filters:[{name:'Runner 配置',extensions:['json']}]})
  639. if(!selection.canceled&&selection.filePaths[0]&&!closing)await runnerAction(()=>runnerManager.importFile(selection.filePaths[0]))
  640. }
  641. Menu.setApplicationMenu(Menu.buildFromTemplate(desktopMenu([
  642. { label: '夭夭', submenu: [{ role: 'about', label: '关于夭夭' }, { type: 'separator' },
  643. { id:'desktop-service-status',label:'正在连接后台服务…',enabled:false },
  644. { label: '显示窗口', click: show }, { label: '在浏览器中打开', click: () => { const url = serviceURL(); if (url) safeExternal(url) } },
  645. { 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'))")}} },
  646. { label: '查看服务日志', click: () => shell.showItemInFolder(logFile) },
  647. { id:'desktop-sync-notice', label:'Web 同步待完成:服务忙碌', enabled:false, visible:false },
  648. { id:'desktop-sync-retry', label:'重试同步本机 Web…', enabled:false,
  649. click:async()=>{try{await manager.retrySynchronization()}catch(error){log(error.message)}} },
  650. { id:'desktop-sync-force', label:'使用当前 App 覆盖同版本 Web', enabled:false,
  651. click:async()=>{if(closing||quitting||!manager.canForceSynchronization)return;try{await manager.retrySynchronization({force:true})}catch(error){log(error.message)}} },
  652. { id:'desktop-login', label:'登录 macOS 时启动', type:'checkbox', enabled:app.isPackaged, checked:app.getLoginItemSettings(loginOptions).openAtLogin,
  653. click:async item=>{try{app.setLoginItemSettings({...loginOptions,openAtLogin:item.checked});item.checked=app.getLoginItemSettings(loginOptions).openAtLogin}catch(error){item.checked=app.getLoginItemSettings(loginOptions).openAtLogin;await dialog.showMessageBox(window,{type:'error',message:'无法修改登录启动设置',detail:error.message})}} },
  654. { id:'desktop-background', label:'登录启动时仅驻留菜单栏', type:'checkbox', checked:preferences.value.backgroundAtLogin,
  655. 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})}} },
  656. { id:'desktop-update-check', label:'检查 App 更新…', click:showUpdates },
  657. { id:'desktop-update-help', label:'App 更新与回退…', click:()=>dialog.showMessageBox(window,{type:'info',message:`夭夭 App ${packageVersion}`,detail:`App 构建:${String(buildInfo.commit).slice(0,12)}\n\n点击侧栏中的“可更新”下载新版,准备完成后点击“重新启动”。此操作只更新当前电脑的 App,不升级远程服务器。开发运行时提供手动安装包下载。${platform.server ? "\n\n独立后台服务继续运行。服务器模式下,首次启动同步较旧的本机 Web,已有较新的 Web 保留。Web 降级通过服务器的回滚入口处理;数据库结构不兼容时不能直接降级。" : ""}\n\n数据目录:${home}`,buttons:['知道了','打开数据目录']}).then(result=>{if(result.response===1)shell.showItemInFolder(home)}) },
  658. { type: 'separator' },
  659. { role: 'hide', label: '隐藏夭夭' }, { role: 'hideOthers', label: '隐藏其他' }, { role: 'unhide', label: '显示全部' },
  660. { type: 'separator' },
  661. {label:'撤销本机控制授权',click:()=>{void environmentHost?.revoke();void hostManager?.revoke()}},
  662. { id:'desktop-quit',label:'退出夭夭(后台继续运行)',accelerator:'CommandOrControl+Q',click:()=>requestQuit() },
  663. { id:'desktop-stop-and-quit',label:'停止后台服务并退出',click:()=>requestQuit(true) }] },
  664. {label:'执行节点',submenu:[{id:'runner-status',label:'未配置执行节点',enabled:false},{type:'separator'},
  665. {id:'runner-import',label:'导入节点配置…',click:importRunner},
  666. {id:'runner-reconnect',label:'重新连接',click:()=>runnerAction(()=>runnerManager.start())},
  667. {id:'runner-forget',label:'断开并忘记配置',click:()=>runnerAction(()=>runnerManager.forget())}]},
  668. {label:'运行模式',submenu:[{id:'desktop-mode-status',label:'请选择运行模式',enabled:false},{type:'separator'},
  669. {id:'desktop-host-use-remote',label:'客户端模式(连接服务器)',enabled:!remoteMode,click:()=>void switchMode('remote')},
  670. {id:'desktop-host-use-local',label:'服务器模式(本机运行)',enabled:!serverModeActive,click:()=>void switchMode('local')},
  671. {label:'更换服务器…',click:()=>{void openRemoteLogin().catch(error=>log(error.message))}},{type:'separator'},
  672. {id:'desktop-host-ask',label:'启动时询问运行模式',type:'checkbox',checked:preferences.value.startupChoice==='ask',
  673. click:item=>{void preferences.setStartupChoice(item.checked?'ask':remoteMode?'remote':'local').catch(async error=>{item.checked=preferences.value.startupChoice==='ask';await appDialog({type:'error',message:'无法保存启动偏好',detail:error.message})})}}]},
  674. {label:'电脑',submenu:[{id:'desktop-host-status',label:'未配置电脑',enabled:false},{type:'separator'},
  675. {id:'desktop-host-authorize',label:'重新授权…',click:()=>{void openRemoteLogin().catch(error=>log(error.message))}},
  676. {id:'desktop-host-import',label:'导入电脑配置…',click:importDesktopHost},
  677. {id:'desktop-host-reconnect',label:'重新连接',click:()=>hostAction(()=>hostManager.start())},
  678. {id:'desktop-host-forget',label:'断开并忘记配置',click:()=>hostAction(()=>hostManager.forget())}]},
  679. { role: 'editMenu', label: '编辑' }, { role: 'viewMenu', label: '显示' }, { role: 'windowMenu', label: '窗口' },
  680. ], platform.platform, { clientOnly: !platform.server })))
  681. const icon = nativeImage.createFromPath(join(desktopRoot, 'icon.png')).resize({ width: 20, height: 20 })
  682. if (!platform.windows) icon.setTemplateImage(true)
  683. tray = new Tray(icon)
  684. tray.setToolTip('夭夭')
  685. tray.setContextMenu(Menu.buildFromTemplate(desktopMenu([{ label: '打开夭夭', click: show }, { label: '查看日志', click: () => shell.showItemInFolder(logFile) }, { type: 'separator' }, { label: '退出夭夭(后台继续运行)', click: () => requestQuit() }, { label: '停止后台服务并退出', click: () => requestQuit(true) }], platform.platform, { clientOnly: !platform.server })))
  686. tray.on('click', show)
  687. // Restore a saved session behind neutral loading feedback. The guide only
  688. // becomes visible if a connection or account actually needs attention.
  689. const startupMode = !platform.server ? 'remote' : preferences.value.startupChoice === 'ask'
  690. ? (fixtureHome && process.env.HERMES_YAOYAO_DESKTOP_TEST_MODE !== 'ask' ? 'local' : null)
  691. : preferences.value.startupChoice
  692. const restoreSession = Boolean(startupMode && (startupMode === 'local' || preferences.value.remoteServer)) && (platform.server || preferences.value.startupChoice !== 'ask')
  693. onboarding.open({ mode: startupMode, serverURL: preferences.value.remoteServer,
  694. remember: preferences.value.startupChoice !== 'ask', restoring: restoreSession })
  695. // Finish the initial navigation before a remembered session enters its server.
  696. await createWindow()
  697. powerMonitor.on('resume', () => { if (!closing && !quitting) { if (serverModeActive) void manager.check(); else void checkRemoteAuthorization() } })
  698. timer = setInterval(() => { if (serverModeActive) void manager.check(); else void checkRemoteAuthorization() }, 10_000); timer.unref()
  699. syncModeMenu()
  700. if (restoreSession)
  701. await onboarding.prepare({ autoEnter: true })
  702. }).catch(error => { log(error.stack || error.message); quitting = true; app.quit() })
  703. }