computer-viewer.mjs 4.5 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576
  1. import { BrowserWindow, ipcMain } from 'electron'
  2. /** Local authenticated viewer. Agent ownership and control leases remain server-authoritative. */
  3. export function installComputerViewer({ owner, origin, preload, quitting = () => false }) {
  4. let viewer, viewerURL, currentTargetURL, approvedClose = false, opening = Promise.resolve()
  5. const trusted = event => event.sender === owner()?.webContents && event.senderFrame === event.sender.mainFrame
  6. && new URL(event.senderFrame.url).origin === origin()
  7. const targetURL = (id, target = {}) => {
  8. if (typeof id !== 'string' || !/^[\w-]{1,128}$/.test(id)) throw new Error('电脑窗口请求无效')
  9. if (!target || typeof target !== 'object' || Array.isArray(target)
  10. || Object.keys(target).some(key => !['backend', 'host'].includes(key))
  11. || (target.backend !== undefined && !['desktop', 'cloud', 'vm'].includes(target.backend))
  12. || (target.host !== undefined && (target.backend !== 'desktop' || typeof target.host !== 'string' || !/^[\w-]{1,128}$/.test(target.host))))
  13. throw new Error('所选电脑无效,请重新选择')
  14. const url = new URL(`/conversations/computer/${encodeURIComponent(id)}`, origin())
  15. if (target.backend) url.searchParams.set('backend', target.backend)
  16. if (target.backend === 'desktop') url.searchParams.set('host', target.host || 'local')
  17. return url.href
  18. }
  19. ipcMain.handle('desktop-computer:open', (event, id, target) => {
  20. if (!trusted(event)) throw new Error('电脑窗口请求无效')
  21. const requestedURL = targetURL(id, target)
  22. const pending = opening.catch(() => {}).then(() => open(requestedURL))
  23. opening = pending
  24. return pending
  25. })
  26. async function open(url) {
  27. if (quitting()) return false
  28. if (viewer && !viewer.isDestroyed()) {
  29. if (currentTargetURL === url) { viewer.show(); viewer.focus(); return true }
  30. // The old page must return its lease before another target can open.
  31. const previous = viewer
  32. await new Promise((resolve, reject) => {
  33. const closed = () => { clearTimeout(timeout); resolve() }
  34. const timeout = setTimeout(() => {
  35. previous.removeListener('closed', closed)
  36. if (!previous.isDestroyed()) { previous.show(); previous.focus() }
  37. reject(new Error('当前电脑尚未交还,请在电脑窗口交还并关闭后重试'))
  38. }, 15000)
  39. previous.once('closed', closed)
  40. previous.close()
  41. })
  42. }
  43. if (quitting()) return false
  44. viewerURL = url; currentTargetURL = url; approvedClose = false
  45. const current = viewer = new BrowserWindow({ parent: owner(), modal: false, width: 1220, height: 820,
  46. minWidth: 760, minHeight: 520, show: false, title: '电脑接管 · 夭夭 AI', backgroundColor: '#111315', autoHideMenuBar: true,
  47. webPreferences: { preload, sandbox: true, contextIsolation: true, nodeIntegration: false, webSecurity: true } })
  48. current.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
  49. current.webContents.on('will-navigate', (event, target) => { if (target !== url) event.preventDefault() })
  50. current.webContents.on('will-redirect', event => event.preventDefault())
  51. current.webContents.on('will-attach-webview', event => event.preventDefault())
  52. current.on('close', event => {
  53. if (!approvedClose && !quitting() && !current.webContents.isCrashed()) {
  54. event.preventDefault(); current.webContents.send('desktop-computer:request-close')
  55. }
  56. })
  57. current.on('closed', () => { if (viewer === current) { viewer = undefined; viewerURL = undefined; currentTargetURL = undefined } })
  58. current.once('ready-to-show', () => { current.show(); current.focus() })
  59. try { await current.loadURL(url) } catch (error) { approvedClose = true; current.close(); throw error }
  60. return true
  61. }
  62. ipcMain.handle('desktop-computer:target', (event, target) => {
  63. if (event.sender !== viewer?.webContents || event.senderFrame !== event.sender.mainFrame
  64. || event.senderFrame.url !== viewerURL) throw new Error('电脑窗口身份无效')
  65. currentTargetURL = targetURL(decodeURIComponent(new URL(viewerURL).pathname.split('/').at(-1)), target)
  66. })
  67. ipcMain.handle('desktop-computer:closed', event => {
  68. if (event.sender !== viewer?.webContents || event.senderFrame !== event.sender.mainFrame
  69. || event.senderFrame.url !== viewerURL) throw new Error('电脑窗口身份无效')
  70. approvedClose = true; viewer.close()
  71. })
  72. const close = () => { approvedClose = true; viewer?.destroy() }
  73. close.owns = contents => contents === viewer?.webContents
  74. return close
  75. }