host-manager.mjs 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130
  1. import {readFile,writeFile,rename,mkdir,rm} from 'node:fs/promises'
  2. import {join} from 'node:path'
  3. import {randomUUID} from 'node:crypto'
  4. import {request as httpRequest} from 'node:http'
  5. import {request as httpsRequest} from 'node:https'
  6. import {parseDesktopHostConfiguration} from './host-config.mjs'
  7. const FATAL = Symbol('desktop-host-fatal')
  8. function remoteExchange(config,body){return new Promise((resolve,reject)=>{
  9. const url=new URL(`/api/desktop-host/v1/${config.hostId}/exchange`,config.serverURL)
  10. if(!['http:','https:'].includes(url.protocol))return reject(new Error('夭夭服务地址无效'))
  11. const bytes=Buffer.from(JSON.stringify(body)),send=url.protocol==='https'?httpsRequest:httpRequest
  12. const req=send(url,{method:'POST',headers:{Authorization:`Bearer ${config.token}`,'x-desktop-host-protocol':'1','content-type':'application/json','content-length':bytes.length}},res=>{
  13. let size=0;const chunks=[]
  14. res.on('data',chunk=>{size+=chunk.length;if(size>256*1024)res.destroy(new Error('桌面命令超过限制'));else chunks.push(chunk)})
  15. res.on('error',reject)
  16. res.on('end',()=>{try{
  17. if(res.statusCode===401||res.statusCode===403){const error=new Error('桌面主机授权已失效,请在夭夭服务中重新注册并导入新配置');error[FATAL]=true;throw error}
  18. if(res.statusCode===409){const error=new Error('桌面主机协议不兼容,请更新夭夭后重新导入');error[FATAL]=true;throw error}
  19. if(res.statusCode!==200)throw new Error(`桌面主机服务未连接(HTTP ${res.statusCode})`)
  20. resolve(JSON.parse(Buffer.concat(chunks).toString()))
  21. }catch(error){reject(error)}})
  22. })
  23. req.on('error',reject)
  24. req.setTimeout(10000,()=>req.destroy(new Error('桌面主机连接超时')))
  25. req.end(bytes)
  26. })}
  27. /** Remote desktop host: imports an encrypted pairing config and exchanges
  28. * commands with the paired server. Native-only; no browser IPC. */
  29. export class DesktopHostManager {
  30. constructor(options){
  31. if(typeof options?.createCore!=='function')throw new Error('桌面主机需要命令执行核心')
  32. this.options=options
  33. this.path=join(options.home,'desktop-host.enc')
  34. this.createCore=options.createCore
  35. this.state='未配置桌面主机'
  36. this.core=undefined
  37. this.config=undefined
  38. this.timer=undefined
  39. this.backoff=400
  40. this.chain=Promise.resolve()
  41. this.generation=0
  42. }
  43. publish(message){this.state=message;this.options.onState?.(message)}
  44. async installIdentity(){
  45. const path=join(this.options.home,'desktop-install-id')
  46. try{const id=(await readFile(path,'utf8')).trim();if(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id))return id}catch(error){if(error.code!=='ENOENT')throw error}
  47. const id=randomUUID()
  48. await mkdir(this.options.home,{recursive:true,mode:0o700})
  49. await writeFile(path,id,{mode:0o600})
  50. return id
  51. }
  52. serial(work){const result=this.chain.catch(()=>{}).then(work);this.chain=result;return result}
  53. async read(){
  54. let encrypted
  55. try{encrypted=await readFile(this.path)}catch(error){if(error.code==='ENOENT')return;throw error}
  56. try{return parseDesktopHostConfiguration(JSON.parse(await this.options.decrypt(encrypted)))}
  57. catch{throw new Error('无法解锁桌面主机配置,请检查钥匙串或重新导入')}
  58. }
  59. importFile(path){
  60. const generation=++this.generation
  61. return this.serial(async()=>{
  62. let config
  63. try{config=parseDesktopHostConfiguration(JSON.parse(await readFile(path,'utf8')))}
  64. catch(error){throw error instanceof Error?error:new Error('桌面主机配置无效,请从夭夭重新下载配置')}
  65. await this.store(config,generation)
  66. })
  67. }
  68. /** Adopt a config obtained in-process (e.g. after a native remote login). */
  69. adopt(value){
  70. const generation=++this.generation
  71. return this.serial(async()=>{
  72. const config=parseDesktopHostConfiguration(value)
  73. await this.store(config,generation)
  74. })
  75. }
  76. async store(config,generation){
  77. // Encrypt before stopping a working host: failed Keychain access preserves it.
  78. if(generation!==this.generation)return
  79. const bytes=await this.options.encrypt(JSON.stringify(config))
  80. if(generation!==this.generation)return
  81. await this.stopLoop()
  82. if(generation!==this.generation)return
  83. await mkdir(this.options.home,{recursive:true,mode:0o700})
  84. const temporary=`${this.path}.${randomUUID()}`
  85. try{await writeFile(temporary,bytes,{flag:'wx',mode:0o600});await rename(temporary,this.path)}
  86. finally{await rm(temporary,{force:true})}
  87. if(generation!==this.generation)return
  88. await this.startLoop(config)
  89. }
  90. start(){const generation=++this.generation;return this.serial(async()=>{const config=await this.read();if(generation!==this.generation)return;if(config)await this.startLoop(config)})}
  91. async startLoop(config){
  92. await this.stopLoop()
  93. this.config=config
  94. this.core=this.createCore({root:this.options.root,dataRoot:this.options.dataRoot})
  95. this.backoff=400
  96. this.publish(`桌面主机正在连接 ${new URL(config.serverURL).host}`)
  97. this.timer=setTimeout(()=>void this.cycle(),400)
  98. this.timer.unref?.()
  99. }
  100. async cycle(){
  101. const config=this.config,core=this.core
  102. if(!config||!core||this.core!==core)return
  103. try{
  104. const value=await remoteExchange(config,{host:core.info(),results:core.takeResults()})
  105. this.backoff=400
  106. if(this.config!==config||this.core!==core)return
  107. this.publish(`桌面主机已连接 ${new URL(config.serverURL).host}`)
  108. await core.handle(value.commands??[])
  109. }catch(error){
  110. if(this.config!==config||this.core!==core)return
  111. if(error&&error[FATAL]){this.publish(error.message);await this.stopLoop();return}
  112. this.backoff=Math.min(this.backoff*2,5000)
  113. this.publish(`桌面主机连接中断,正在重试:${String(error.message).slice(0,120)}`)
  114. }finally{
  115. if(this.core===core){this.timer=setTimeout(()=>void this.cycle(),this.backoff);this.timer.unref?.()}
  116. }
  117. }
  118. async stopLoop(){
  119. clearTimeout(this.timer);this.timer=undefined
  120. const core=this.core
  121. this.core=undefined;this.config=undefined
  122. if(core)await core.close().catch(()=>{})
  123. }
  124. stop(){this.generation++;return this.serial(()=>this.stopLoop())}
  125. forget(){this.generation++;return this.serial(async()=>{await this.stopLoop();await rm(this.path,{force:true});this.publish('未配置桌面主机')})}
  126. revoke(){return this.serial(async()=>{await this.core?.revoke()})}
  127. }