main.mjs 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494
  1. import { app, BrowserWindow, ipcMain, Menu, Tray, nativeImage, shell, utilityProcess, powerMonitor, systemPreferences, dialog, safeStorage, net, session as electronSession } from 'electron'
  2. import { appendFileSync, mkdirSync, readFileSync, realpathSync } from 'node:fs'
  3. import { join, dirname } from 'node:path'
  4. import { homedir, hostname } 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, migrateLocalData } from './service-sync.mjs'
  11. import { resolveDataHome } from './data-home.mjs'
  12. import { DesktopEnvironmentHost, DesktopHostCore } from './environment-host.mjs'
  13. import { DesktopHostManager } from './host-manager.mjs'
  14. import { remoteSession, enrollDesktopHost, normalizeServerURL } from './remote-login.mjs'
  15. import { DesktopUpdateManager } from './update-manager.mjs'
  16. import { installComputerViewer } from './computer-viewer.mjs'
  17. const desktopRoot = dirname(fileURLToPath(import.meta.url))
  18. const fixtureHome = process.env.HERMES_YAOYAO_DESKTOP_TEST_HOME
  19. // Fixture identity also isolates Chromium cookies, window state and the app lock.
  20. if (fixtureHome) app.setPath('userData', join(fixtureHome, 'desktop'))
  21. app.setName('夭夭')
  22. if (!app.requestSingleInstanceLock()) { app.quit() }
  23. else {
  24. let window, tray, manager, runnerManager, quitting = false, closing = false, timer
  25. let updateWindow, updater, environmentHost, hostManager, loginWindow
  26. let remoteMode = false, remoteURL = '', serverModeActive = false, switchingMode = false
  27. const serviceURL = () => remoteMode ? remoteURL : manager?.state.url
  28. const serviceOrigins = () => {
  29. try { return remoteMode && remoteURL ? [new URL(remoteURL).origin] : manager?.state.url ? [new URL(manager.state.url).origin] : [] }
  30. catch { return [] }
  31. }
  32. const closeComputerViewer = installComputerViewer({ owner: () => window, origin: () => serviceURL(),
  33. preload: join(desktopRoot, 'preload.cjs'), quitting: () => quitting })
  34. app.on('will-quit', closeComputerViewer)
  35. const updateURL = pathToFileURL(join(desktopRoot, 'update.html')).href
  36. const bootURL = pathToFileURL(join(desktopRoot, 'boot.html')).href
  37. const root = app.isPackaged ? join(process.resourcesPath, 'runtime') : join(app.getAppPath(), '.desktop-build')
  38. const logRoot = join(app.getPath('logs'), fixtureHome ? 'verification' : 'service')
  39. mkdirSync(logRoot, { recursive: true })
  40. const logFile = join(logRoot, 'server.log')
  41. const log = text => appendFileSync(logFile, `${new Date().toISOString()} ${text.trim()}\n`, { mode: 0o600 })
  42. const home = fixtureHome || resolveDataHome(process.env.YAOYAO_HOME || process.env.HERMES_YAOYAO_HOME)
  43. const preferences = new DesktopPreferences(home)
  44. let startHidden = false
  45. const port = Number(process.env.HERMES_YAOYAO_DESKTOP_PORT || 15300)
  46. if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('桌面服务端口无效')
  47. const trustedBoot = event => event.sender === window?.webContents && event.senderFrame?.url === bootURL
  48. ipcMain.handle('desktop:status', event => {
  49. if (!trustedBoot(event)) throw new Error('不允许此页面访问桌面服务')
  50. return { phase: manager?.state.phase || 'starting', message: manager?.state.message || '正在启动…', canForceSync: manager?.state.canForceSync === true }
  51. })
  52. ipcMain.handle('desktop:retry', async event => {
  53. if (!trustedBoot(event)) throw new Error('不允许此页面启动桌面服务')
  54. manager.restarts = []
  55. try { await manager.start(); return true } catch { return false }
  56. })
  57. ipcMain.handle('desktop:logs', event => {
  58. if (!trustedBoot(event)) throw new Error('不允许此页面读取日志')
  59. shell.showItemInFolder(logFile)
  60. })
  61. ipcMain.handle('desktop:force-sync', async event => {
  62. if (!trustedBoot(event) || event.senderFrame !== window.webContents.mainFrame || closing || quitting || !manager?.canForceSynchronization)
  63. throw new Error('不允许此页面覆盖本机 Web')
  64. try { await manager.retrySynchronization({ force: true }); return true }
  65. catch (error) { log(error.message); return false }
  66. })
  67. const trustedUpdate = event => event.sender === updateWindow?.webContents
  68. && event.senderFrame === updateWindow.webContents.mainFrame && event.senderFrame.url === updateURL
  69. ipcMain.handle('desktop:updates', async event => {
  70. let trusted = false
  71. try {
  72. trusted = event.sender === window?.webContents && event.senderFrame === window.webContents.mainFrame
  73. && manager?.state.phase === 'ready' && new URL(event.senderFrame.url).origin === manager.state.url
  74. } catch { /* Only the main local application page may open the updater. */ }
  75. if (!trusted || closing || quitting || !updater) throw new Error('不允许此页面打开 App 更新')
  76. await showUpdates()
  77. })
  78. for (const action of ['state', 'check', 'download', 'cancel', 'open', 'folder', 'release']) {
  79. ipcMain.handle(`desktop-update:${action}`, async event => {
  80. if (!trustedUpdate(event) || closing || quitting) throw new Error('不允许此页面操作 App 更新')
  81. if (action === 'state') return updater.snapshot()
  82. if (action === 'check') return updater.check()
  83. if (action === 'download') return updater.download()
  84. if (action === 'cancel') { updater.cancel(); return updater.snapshot() }
  85. if (action === 'release') { safeExternal(updater.snapshot().releasePageUrl || 'https://github.com/Lsamien/yaoyao/releases'); return }
  86. const path = await updater.verifiedFile()
  87. if (closing || quitting || !trustedUpdate(event)) return
  88. if (action === 'folder') shell.showItemInFolder(path)
  89. else { const error = await shell.openPath(path); if (error) throw new Error(error) }
  90. })
  91. }
  92. async function showUpdates() {
  93. if (closing || quitting) return
  94. if (updateWindow && !updateWindow.isDestroyed()) { updateWindow.show(); updateWindow.focus(); return }
  95. updateWindow = new BrowserWindow({ title: 'App 更新 · 夭夭', width: 640, height: 620, minWidth: 360, minHeight: 440, show: false,
  96. webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true, webSecurity: true,
  97. preload: join(desktopRoot, 'update-preload.cjs') } })
  98. updateWindow.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
  99. updateWindow.webContents.on('will-navigate', event => event.preventDefault())
  100. updateWindow.webContents.on('will-redirect', event => event.preventDefault())
  101. updateWindow.webContents.on('will-attach-webview', event => event.preventDefault())
  102. updateWindow.on('closed', () => { updater.cancel(); updateWindow = undefined })
  103. updateWindow.once('ready-to-show', () => updateWindow?.show())
  104. await updateWindow.loadURL(updateURL)
  105. }
  106. function show() {
  107. if (closing || quitting) return
  108. if (!window || window.isDestroyed()) createWindow()
  109. window.show(); window.focus()
  110. if (serverModeActive && manager) void manager.reconnect().then(() => stateChanged(manager.state)).catch(error => log(error.message))
  111. }
  112. function safeExternal(url) {
  113. try { const parsed = new URL(url); if (['https:', 'http:', 'mailto:'].includes(parsed.protocol)) void shell.openExternal(url) }
  114. catch { /* No arbitrary file, shell or custom URL dispatch. */ }
  115. }
  116. function createWindow() {
  117. window = new BrowserWindow({ width: 1280, height: 860, minWidth: 720, minHeight: 560, show: false,
  118. title: '夭夭', backgroundColor: '#f7f7f7',
  119. webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true,
  120. webSecurity: true, preload: join(desktopRoot, 'preload.cjs') } })
  121. window.webContents.setWindowOpenHandler(({ url }) => {
  122. try {
  123. if (serviceOrigins().includes(new URL(url).origin)) void window.loadURL(url)
  124. else safeExternal(url)
  125. } catch { /* Invalid navigation is denied. */ }
  126. return { action: 'deny' }
  127. })
  128. const guardNavigation = (event, url) => {
  129. try { if (url === bootURL || serviceOrigins().includes(new URL(url).origin)) return }
  130. catch { /* Invalid navigation is denied. */ }
  131. event.preventDefault(); safeExternal(url)
  132. }
  133. window.webContents.on('will-navigate', guardNavigation)
  134. window.webContents.on('will-redirect', guardNavigation)
  135. window.webContents.on('will-attach-webview', event => event.preventDefault())
  136. const trustedService = (contents, url) => {
  137. try { return contents === window?.webContents && serviceOrigins().includes(new URL(url).origin) }
  138. catch { return false }
  139. }
  140. window.webContents.session.setPermissionCheckHandler((contents, permission, origin) =>
  141. closeComputerViewer.owns(contents) ? ['fullscreen', 'clipboard-sanitized-write'].includes(permission)
  142. : trustedService(contents, origin) && ['media', 'notifications', 'clipboard-sanitized-write'].includes(permission))
  143. window.webContents.session.setPermissionRequestHandler((contents, permission, callback, details) => {
  144. if (closeComputerViewer.owns(contents)) { callback(['fullscreen', 'clipboard-sanitized-write'].includes(permission)); return }
  145. if (!trustedService(contents, details.requestingUrl)) { callback(false); return }
  146. if (permission === 'media' && details.mediaTypes?.length && details.mediaTypes.every(type => type === 'audio')) {
  147. void systemPreferences.askForMediaAccess('microphone').then(callback, () => callback(false))
  148. } else callback(['notifications', 'clipboard-sanitized-write', 'fullscreen'].includes(permission))
  149. })
  150. window.on('close', event => { if (!quitting) { event.preventDefault(); window.hide() } })
  151. window.once('ready-to-show', () => { if (!startHidden) window.show() })
  152. if (remoteMode && remoteURL) void window.loadURL(remoteURL)
  153. else if (manager?.state.phase === 'ready') void window.loadURL(manager.state.url)
  154. else void window.loadFile(join(desktopRoot, 'boot.html'))
  155. }
  156. function stateChanged(state) {
  157. log(`${state.phase}: ${state.message}`)
  158. // Quit owns the window from this point on. A detached connection is not a
  159. // stopped server and must never navigate the closing window to boot.html.
  160. if (closing || quitting || remoteMode) return
  161. const serviceStatus=Menu.getApplicationMenu()?.getMenuItemById('desktop-service-status')
  162. if(serviceStatus)serviceStatus.label=state.phase==='ready'?'后台服务运行中':state.message
  163. const localVmMenu=Menu.getApplicationMenu()?.getMenuItemById('desktop-local-vm')
  164. if(localVmMenu)localVmMenu.enabled=state.phase==='ready'
  165. const notice = Menu.getApplicationMenu()?.getMenuItemById('desktop-sync-notice')
  166. if (notice) notice.visible = Boolean(state.updateNotice)
  167. const retrySync = Menu.getApplicationMenu()?.getMenuItemById('desktop-sync-retry')
  168. if (retrySync) retrySync.enabled = state.phase === 'ready' && Boolean(manager?.options.synchronize)
  169. const forceSync = Menu.getApplicationMenu()?.getMenuItemById('desktop-sync-force')
  170. if (forceSync) forceSync.enabled = !closing && !quitting && manager?.canForceSynchronization === true
  171. tray?.setToolTip(`夭夭 · ${state.message}`)
  172. if (!window || window.isDestroyed()) return
  173. if (state.phase === 'ready') {
  174. const current = window.webContents.getURL()
  175. if (!current.startsWith(`${state.url}/`) && current !== state.url) void window.loadURL(state.url)
  176. } else if (window.webContents.getURL() !== bootURL) void window.loadFile(join(desktopRoot, 'boot.html'))
  177. }
  178. app.on('second-instance', show)
  179. app.on('activate', show)
  180. app.on('window-all-closed', () => {})
  181. function requestQuit(stopBackground = false) {
  182. if (closing || quitting) return
  183. closing = true; clearInterval(timer)
  184. updater?.cancel(); updateWindow?.close(); loginWindow?.close()
  185. window?.hide()
  186. // Native Cmd+Q can enter before-quit from Cocoa's termination callback.
  187. // Both cleanup and the final quit must run after that callback unwinds.
  188. setImmediate(() => { void (async()=>{
  189. try {
  190. await environmentHost?.close()
  191. await hostManager?.stop()
  192. await runnerManager?.stop()
  193. if (stopBackground) await manager?.stopBackground()
  194. else await manager?.stop()
  195. quitting = true
  196. setImmediate(() => app.quit())
  197. } catch(error) {
  198. log(error.message)
  199. window?.show()
  200. const choice=await dialog.showMessageBox(window,{type:'warning',message:stopBackground?'后台服务停止未完成':'App 退出清理未完成',detail:error.message,buttons:['返回应用','仅退出 App'],defaultId:0,cancelId:0})
  201. if(choice.response===1){quitting=true;setImmediate(()=>app.quit())}
  202. else {
  203. closing=false
  204. if(remoteMode)await hostManager?.start().catch(error=>log(error.message))
  205. else{
  206. await manager?.reconnect().catch(error=>log(error.message))
  207. await runnerManager?.start().catch(error=>log(error.message))
  208. }
  209. stateChanged(manager.state)
  210. timer=setInterval(()=>{if(serverModeActive)void manager.check()},10000);timer.unref()
  211. show()
  212. }
  213. }
  214. })() })
  215. }
  216. app.on('before-quit', event => {
  217. if (quitting) return
  218. event.preventDefault()
  219. requestQuit()
  220. })
  221. app.whenReady().then(async () => {
  222. await preferences.load()
  223. startHidden = preferences.value.backgroundAtLogin && app.getLoginItemSettings().wasOpenedAtLogin
  224. const packageVersion = JSON.parse(readFileSync(join(root, 'release.json'), 'utf8')).webVersion
  225. const buildInfo=JSON.parse(readFileSync(join(root,'build-info.json'),'utf8'))
  226. const releases = await import(pathToFileURL(join(root, 'github-release.mjs')).href)
  227. updater = new DesktopUpdateManager({ version: packageVersion, cacheRoot: join(home, 'updates', 'desktop-downloads'),
  228. source: releases.DEFAULT_RELEASE_SOURCE, inspect: releases.inspectGitHubRelease, compare: releases.compareReleaseVersions,
  229. fetchImpl: (...args) => net.fetch(...args) })
  230. app.setAboutPanelOptions({applicationName:'夭夭',applicationVersion:packageVersion,version:`${String(buildInfo.commit).slice(0,12)}${buildInfo.dirty?' · 工作区快照':''}`})
  231. manager = new DesktopServiceManager({ home, port, version: packageVersion, log, onState: stateChanged,
  232. prepareHome: !fixtureHome ? onProgress => migrateLocalData({ home, port, root, onProgress }) : undefined,
  233. stopBackground: (app.isPackaged && !fixtureHome) || (fixtureHome && process.env.HERMES_YAOYAO_DESKTOP_TEST_SYNC === '1')
  234. ? onProgress => stopLocalService({home,port,root,fixture:Boolean(fixtureHome),onProgress}) : undefined,
  235. synchronize: (app.isPackaged && !fixtureHome) || (fixtureHome && process.env.HERMES_YAOYAO_DESKTOP_TEST_SYNC === '1')
  236. ? (onProgress, { force = false } = {}) => synchronizeLocalService({ home, port, root, fixture: Boolean(fixtureHome), onProgress, force,
  237. environment: { HERMES_YAOYAO_UPSTREAM: process.env.HERMES_YAOYAO_UPSTREAM || 'http://127.0.0.1:9119', HERMES_YAOYAO_SUPERVISE_DASHBOARD: fixtureHome ? '0' : '1' } })
  238. : undefined,
  239. fork: ({ home, port }) => {
  240. const env = { ...process.env, NODE_ENV: 'production', NODE_USE_ENV_PROXY: '0',
  241. PATH: [join(homedir(), '.local/bin'), '/opt/homebrew/bin', '/usr/local/bin', process.env.PATH || '/usr/bin:/bin'].join(':'),
  242. HERMES_YAOYAO_DESKTOP: '1', HERMES_YAOYAO_HOME: home, HERMES_YAOYAO_HOST: '127.0.0.1',
  243. HERMES_YAOYAO_PORT: String(port), HERMES_YAOYAO_STATIC_DIR: join(root, 'ui'),
  244. HERMES_YAOYAO_SUPERVISE_DASHBOARD: fixtureHome ? '0' : '1',
  245. HERMES_YAOYAO_UPSTREAM: process.env.HERMES_YAOYAO_UPSTREAM || 'http://127.0.0.1:9119' }
  246. delete env.ELECTRON_RUN_AS_NODE
  247. delete env.HERMES_YAOYAO_TLS_CERT; delete env.HERMES_YAOYAO_TLS_KEY
  248. return utilityProcess.fork(join(root, 'server.mjs'), [], { cwd: root, env, stdio: 'pipe' })
  249. },
  250. })
  251. environmentHost=new DesktopEnvironmentHost({manager,root,dataRoot:app.getPath('userData')})
  252. const { parseRunnerConfiguration } = await import(pathToFileURL(join(root,'runner-config.mjs')).href)
  253. const credentials=new DesktopCredentials({home,helper:join(root,app.isPackaged?'keychain-helper':'keychain-helper-dev'),legacyDecrypt:async bytes=>(await safeStorage.decryptStringAsync(bytes)).result})
  254. runnerManager = new DesktopRunnerManager({home,validate:parseRunnerConfiguration,
  255. encrypt:value=>credentials.encrypt(value),
  256. decrypt:bytes=>credentials.decrypt(bytes),
  257. onState:message=>{log(`Runner: ${message}`);const item=Menu.getApplicationMenu()?.getMenuItemById('runner-status');if(item)item.label=message},
  258. 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'}}),
  259. })
  260. async function runnerAction(action){try{await action()}catch(error){await dialog.showMessageBox(window,{type:'error',message:'执行节点操作未完成',detail:error.message})}}
  261. hostManager=new DesktopHostManager({home,root,dataRoot:app.getPath('userData'),
  262. createCore:({root,dataRoot})=>new DesktopHostCore({root,dataRoot}),
  263. encrypt:value=>credentials.encrypt(value),
  264. decrypt:bytes=>credentials.decrypt(bytes),
  265. onState:message=>{log(`桌面主机: ${message}`);const item=Menu.getApplicationMenu()?.getMenuItemById('desktop-host-status');if(item)item.label=message}})
  266. ipcMain.handle('desktop:device-host', async event => {
  267. try {
  268. const trusted = event.sender === window?.webContents && event.senderFrame === event.sender.mainFrame
  269. && serviceOrigins().includes(new URL(event.senderFrame.url).origin)
  270. if (!trusted || closing || quitting) throw new Error('不允许此页面读取设备身份')
  271. } catch (error) {
  272. if (error.message === '不允许此页面读取设备身份') throw error
  273. throw new Error('不允许此页面读取设备身份')
  274. }
  275. if (remoteMode) {
  276. const enrolled = await hostManager.read().catch(() => undefined)
  277. return { deviceHost: enrolled && new URL(enrolled.serverURL).origin === new URL(remoteURL).origin ? enrolled.hostId : null }
  278. }
  279. return { deviceHost: 'local' }
  280. })
  281. async function hostAction(action){try{await action()}catch(error){await dialog.showMessageBox(window,{type:'error',message:'桌面主机操作未完成',detail:error.message})}}
  282. const appDialog = options => window && !window.isDestroyed() ? dialog.showMessageBox(window, options) : dialog.showMessageBox(options)
  283. function requireModePage(event){
  284. let trusted=false
  285. try{trusted=event.sender===window?.webContents&&event.senderFrame===window.webContents.mainFrame
  286. &&serviceOrigins().includes(new URL(event.senderFrame.url).origin)}catch{}
  287. if(!trusted||closing||quitting)throw new Error('不允许此页面切换桌面运行模式')
  288. }
  289. ipcMain.handle('desktop:mode-state',event=>{
  290. requireModePage(event)
  291. return {mode:remoteMode?'client':'server',serverURL:serviceURL()||'',switching:switchingMode}
  292. })
  293. ipcMain.handle('desktop:mode-switch',async(event,mode)=>{
  294. requireModePage(event)
  295. if(mode!=='client'&&mode!=='server')throw new Error('运行模式无效')
  296. return switchMode(mode==='client'?'remote':'local',false)
  297. })
  298. ipcMain.handle('desktop:remote-login',event=>{
  299. requireModePage(event)
  300. if(switchingMode)throw new Error('正在切换运行模式,请稍候')
  301. openRemoteLogin()
  302. })
  303. async function importDesktopHost(){
  304. const selection=window&&!window.isDestroyed()
  305. ?await dialog.showOpenDialog(window,{title:'导入桌面主机配置',properties:['openFile'],filters:[{name:'桌面主机配置',extensions:['json']}]})
  306. :await dialog.showOpenDialog({title:'导入桌面主机配置',properties:['openFile'],filters:[{name:'桌面主机配置',extensions:['json']}]})
  307. if(!selection.canceled&&selection.filePaths[0]&&!closing)await hostAction(()=>hostManager.importFile(selection.filePaths[0]))
  308. }
  309. function openRemoteLogin(){
  310. if(closing||quitting)return
  311. if(loginWindow&&!loginWindow.isDestroyed()){loginWindow.show();loginWindow.focus();return}
  312. loginWindow=new BrowserWindow({width:420,height:580,minWidth:360,minHeight:440,show:false,title:'登录远程',backgroundColor:'#f7f7f7',
  313. webPreferences:{contextIsolation:true,nodeIntegration:false,sandbox:true,webSecurity:true,preload:join(desktopRoot,'remote-login-preload.cjs')}})
  314. loginWindow.once('ready-to-show',()=>{if(loginWindow)loginWindow.show()})
  315. loginWindow.on('closed',()=>{loginWindow=undefined})
  316. const stored=preferences.value.remoteServer
  317. void loginWindow.loadFile(join(desktopRoot,'remote-login.html'),stored?{query:{server:stored}}:undefined)
  318. }
  319. async function enterRemoteMode(){
  320. const enrolled=await hostManager.read().catch(error=>{log(error.message);return undefined})
  321. let server=''
  322. const candidate=preferences.value.remoteServer||enrolled?.serverURL
  323. if(candidate){try{server=normalizeServerURL(candidate)}catch(error){log(error.message)}}
  324. if(!server){openRemoteLogin();return false}
  325. serverModeActive=false
  326. await environmentHost?.close().catch(error=>log(error.message))
  327. await runnerManager?.stop().catch(error=>log(error.message))
  328. await manager?.stop().catch(error=>log(error.message))
  329. remoteMode=true;remoteURL=server
  330. await preferences.setRemoteServer(server).catch(error=>log(error.message))
  331. await preferences.setStartupChoice('remote').catch(error=>log(error.message))
  332. if(enrolled&&new URL(enrolled.serverURL).origin===new URL(server).origin)await hostManager.start().catch(error=>{hostManager.publish(error.message)})
  333. const status=Menu.getApplicationMenu()?.getMenuItemById('desktop-service-status')
  334. if(status)status.label=`已登录远程 · ${new URL(server).host}`
  335. tray?.setToolTip(`夭夭 · 远程 ${new URL(server).host}`)
  336. syncModeMenu()
  337. log(`远程模式:${server}`)
  338. if(!fixtureHome){
  339. try{
  340. const probe=await net.fetch(new URL('/api/app/bootstrap',server),{headers:{accept:'application/json'},redirect:'error'})
  341. const info=await probe.json().catch(()=>undefined)
  342. if(info?.authenticated!==true)openRemoteLogin()
  343. }catch{openRemoteLogin()}
  344. }
  345. return true
  346. }
  347. function syncModeMenu(){
  348. const menu=Menu.getApplicationMenu()
  349. const useRemote=menu?.getMenuItemById('desktop-host-use-remote'),useLocal=menu?.getMenuItemById('desktop-host-use-local'),ask=menu?.getMenuItemById('desktop-host-ask')
  350. if(useRemote)useRemote.enabled=!switchingMode&&!remoteMode
  351. if(useLocal)useLocal.enabled=!switchingMode&&!serverModeActive
  352. const status=menu?.getMenuItemById('desktop-mode-status')
  353. if(status)status.label=switchingMode?'正在切换运行模式…':remoteMode?'当前:客户端模式':serverModeActive?'当前:服务器模式':'请选择运行模式'
  354. if(ask)ask.checked=preferences.value.startupChoice==='ask'
  355. }
  356. async function switchMode(mode,notify=true){
  357. if(closing||quitting)return {ok:false,error:'App 正在退出'}
  358. if(switchingMode)return {ok:false,error:'正在切换运行模式,请稍候'}
  359. if(mode==='remote'&&remoteMode||mode==='local'&&serverModeActive)return {ok:true}
  360. switchingMode=true;syncModeMenu()
  361. try{
  362. if(mode==='remote'){
  363. if(await enterRemoteMode()){
  364. if(window&&!window.isDestroyed())void window.loadURL(remoteURL)
  365. }else return {ok:true,pendingLogin:true}
  366. }else{
  367. loginWindow?.close()
  368. serverModeActive=true;remoteMode=false;remoteURL=''
  369. await hostManager?.stop().catch(error=>log(error.message))
  370. await preferences.setStartupChoice('local').catch(error=>log(error.message))
  371. if(window&&!window.isDestroyed())void window.loadFile(join(desktopRoot,'boot.html'))
  372. await manager?.start().catch(error=>log(error.message))
  373. if(!closing)environmentHost?.start()
  374. }
  375. return {ok:true}
  376. }catch(error){
  377. if(notify)await appDialog({type:'error',message:'切换使用方式未完成',detail:error.message})
  378. return {ok:false,error:error.message}
  379. }finally{switchingMode=false;syncModeMenu()}
  380. }
  381. ipcMain.handle('desktop-remote-login:submit',async(_event,value)=>{
  382. if(closing||quitting)return {ok:false,error:'App 正在退出'}
  383. try{
  384. const server=normalizeServerURL(value?.serverURL)
  385. const auth=await remoteSession(server,{username:value?.username,password:value?.password})
  386. let registered=false
  387. if(auth.user?.role==='admin'){
  388. const registration=await enrollDesktopHost(auth,{name:hostname(),installId:await hostManager.installIdentity()})
  389. await hostManager.adopt({protocol:1,serverURL:server,hostId:registration.host.id,token:registration.token,...(server.startsWith('http://')?{allowInsecureLan:true}:{})})
  390. registered=true
  391. }
  392. try{
  393. for(const [name,cookie] of auth.cookies)
  394. await electronSession.defaultSession.cookies.set({url:server,name,value:cookie,httpOnly:true,secure:server.startsWith('https://')})
  395. }catch(error){log(`远程会话 Cookie 写入失败:${error.message}`)}
  396. await preferences.setRemoteServer(server)
  397. await enterRemoteMode()
  398. if(!window||window.isDestroyed())createWindow()
  399. else void window.loadURL(server)
  400. loginWindow?.close()
  401. if(!registered)await appDialog({type:'info',message:'已登录远程服务器',detail:'当前账号不是管理员:窗口将使用远程夭夭。要把这台 Mac 注册为可远程控制的桌面主机,请使用管理员账号重新登录。'})
  402. return {ok:true,hostRegistered:registered,user:{username:auth.user.username,role:auth.user.role}}
  403. }catch(error){return {ok:false,error:error instanceof Error?error.message:'登录失败'}}
  404. })
  405. ipcMain.handle('desktop-remote-login:use-local',async()=>{
  406. loginWindow?.close()
  407. if(!serverModeActive){
  408. if(!window||window.isDestroyed())createWindow()
  409. await switchMode('local')
  410. }
  411. return {ok:true}
  412. })
  413. async function chooseStartupMode(){
  414. if(fixtureHome)return 'local'
  415. const stored=preferences.value.startupChoice
  416. if(stored==='local'||stored==='remote')return stored
  417. const choice=await dialog.showMessageBox({type:'question',title:'夭夭',message:'这台 Mac 如何使用夭夭?',
  418. detail:'「登录远程」用夭夭服务器的地址和账号密码登录另一台电脑上的夭夭:本机不运行 Web 服务,窗口直接使用远程夭夭;管理员账号还会把这台 Mac 注册为可远程控制的桌面主机。只有「作为服务器运行」会启动这台 Mac 的服务,并使用服务器上的 Hermes。客户端不需要安装 Hermes。',
  419. buttons:['连接服务器(客户端)','作为服务器运行'],defaultId:0,cancelId:0,checkboxLabel:'记住选择,之后启动不再询问',checkboxChecked:false,noLink:true})
  420. const mode=choice.response===0?'remote':'local'
  421. if(choice.checkboxChecked)await preferences.setStartupChoice(mode).catch(error=>log(error.message))
  422. return mode
  423. }
  424. async function importRunner(){
  425. const selection=await dialog.showOpenDialog(window,{title:'导入执行节点配置',properties:['openFile'],filters:[{name:'Runner 配置',extensions:['json']}]})
  426. if(!selection.canceled&&selection.filePaths[0]&&!closing)await runnerAction(()=>runnerManager.importFile(selection.filePaths[0]))
  427. }
  428. Menu.setApplicationMenu(Menu.buildFromTemplate([
  429. { label: '夭夭', submenu: [{ role: 'about', label: '关于夭夭' }, { type: 'separator' },
  430. { id:'desktop-service-status',label:'正在连接后台服务…',enabled:false },
  431. { label: '显示窗口', click: show }, { label: '在浏览器中打开', click: () => { const url = serviceURL(); if (url) safeExternal(url) } },
  432. { 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'))")}} },
  433. { label: '查看服务日志', click: () => shell.showItemInFolder(logFile) },
  434. { id:'desktop-sync-notice', label:'Web 同步待完成:服务忙碌', enabled:false, visible:false },
  435. { id:'desktop-sync-retry', label:'重试同步本机 Web…', enabled:false,
  436. click:async()=>{try{await manager.retrySynchronization()}catch(error){log(error.message)}} },
  437. { id:'desktop-sync-force', label:'使用当前 App 覆盖同版本 Web', enabled:false,
  438. click:async()=>{if(closing||quitting||!manager.canForceSynchronization)return;try{await manager.retrySynchronization({force:true})}catch(error){log(error.message)}} },
  439. { id:'desktop-login', label:'登录 macOS 时启动', type:'checkbox', enabled:app.isPackaged, checked:app.getLoginItemSettings().openAtLogin,
  440. 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})}} },
  441. { id:'desktop-background', label:'登录启动时仅驻留菜单栏', type:'checkbox', checked:preferences.value.backgroundAtLogin,
  442. 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})}} },
  443. { id:'desktop-update-check', label:'检查 App 更新…', click:showUpdates },
  444. { 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)}) },
  445. { type: 'separator' },
  446. { role: 'hide', label: '隐藏夭夭' }, { role: 'hideOthers', label: '隐藏其他' }, { role: 'unhide', label: '显示全部' },
  447. { type: 'separator' },
  448. {label:'撤销本机控制授权',click:()=>{void environmentHost.revoke();void hostManager?.revoke()}},
  449. { id:'desktop-quit',label:'退出夭夭(后台继续运行)',accelerator:'CommandOrControl+Q',click:()=>requestQuit() },
  450. { id:'desktop-stop-and-quit',label:'停止后台服务并退出',click:()=>requestQuit(true) }] },
  451. {label:'执行节点',submenu:[{id:'runner-status',label:'未配置执行节点',enabled:false},{type:'separator'},
  452. {id:'runner-import',label:'导入节点配置…',click:importRunner},
  453. {id:'runner-reconnect',label:'重新连接',click:()=>runnerAction(()=>runnerManager.start())},
  454. {id:'runner-forget',label:'断开并忘记配置',click:()=>runnerAction(()=>runnerManager.forget())}]},
  455. {label:'运行模式',submenu:[{id:'desktop-mode-status',label:'请选择运行模式',enabled:false},{type:'separator'},
  456. {id:'desktop-host-use-remote',label:'客户端模式(连接服务器)',enabled:!remoteMode,click:()=>void switchMode('remote')},
  457. {id:'desktop-host-use-local',label:'服务器模式(本机运行)',enabled:!serverModeActive,click:()=>void switchMode('local')},
  458. {label:'更换服务器…',click:openRemoteLogin},{type:'separator'},
  459. {id:'desktop-host-ask',label:'启动时询问运行模式',type:'checkbox',checked:preferences.value.startupChoice==='ask',
  460. 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})})}}]},
  461. {label:'桌面主机',submenu:[{id:'desktop-host-status',label:'未配置桌面主机',enabled:false},{type:'separator'},
  462. {id:'desktop-host-import',label:'导入主机配置…',click:importDesktopHost},
  463. {id:'desktop-host-reconnect',label:'重新连接',click:()=>hostAction(()=>hostManager.start())},
  464. {id:'desktop-host-forget',label:'断开并忘记配置',click:()=>hostAction(()=>hostManager.forget())}]},
  465. { role: 'editMenu', label: '编辑' }, { role: 'viewMenu', label: '显示' }, { role: 'windowMenu', label: '窗口' },
  466. ]))
  467. const icon = nativeImage.createFromPath(join(desktopRoot, 'icon.png')).resize({ width: 20, height: 20 })
  468. icon.setTemplateImage(true)
  469. tray = new Tray(icon)
  470. tray.setToolTip('夭夭')
  471. tray.setContextMenu(Menu.buildFromTemplate([{ label: '打开夭夭', click: show }, { label: '查看日志', click: () => shell.showItemInFolder(logFile) }, { type: 'separator' }, { label: '退出夭夭(后台继续运行)', click: () => requestQuit() }, { label: '停止后台服务并退出', click: () => requestQuit(true) }]))
  472. tray.on('click', show)
  473. let startupRemote=false,startupLogin=false
  474. serverModeActive=(await chooseStartupMode())==='local'
  475. if(!serverModeActive){
  476. const enrolled=await hostManager.read().catch(()=>undefined)
  477. if(preferences.value.remoteServer||enrolled)startupRemote=await enterRemoteMode()
  478. startupLogin=!startupRemote
  479. }
  480. if(!startupLogin)createWindow()
  481. powerMonitor.on('resume', () => { if(!closing&&!quitting&&serverModeActive)void manager.check() })
  482. timer = setInterval(() => { if(serverModeActive)void manager.check() }, 10_000); timer.unref()
  483. if(startupLogin)openRemoteLogin()
  484. else if(serverModeActive){
  485. syncModeMenu()
  486. await manager.start().catch(error => log(error.message))
  487. if(!closing)environmentHost.start()
  488. }
  489. if(!startupLogin&&!startupRemote&&manager.state.phase==='ready')await runnerManager.start().catch(error=>{runnerManager.publish(error.message)})
  490. }).catch(error => { log(error.stack || error.message); quitting = true; app.quit() })
  491. }