host-manager.mjs 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176
  1. import {readFile,writeFile,rename,mkdir,rm,link} 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 checkExchangeStatus(status){
  9. if(status===401||status===403){const error=new Error('电脑授权已失效,请重新登录授权这台电脑');error[FATAL]=true;throw error}
  10. if(status===409){const error=new Error('电脑连接协议不兼容,请更新夭夭后重新授权');error[FATAL]=true;throw error}
  11. if(status!==200)throw new Error(`电脑未连接(HTTP ${status})`)
  12. }
  13. async function fetchExchange(config,body,fetchImpl){
  14. const url=new URL(`/api/desktop-host/v1/${config.hostId}/exchange`,config.serverURL)
  15. if(!['http:','https:'].includes(url.protocol))throw new Error('夭夭服务地址无效')
  16. const signal=AbortSignal.timeout(10000)
  17. try{
  18. const response=await fetchImpl(url.href,{method:'POST',credentials:'omit',redirect:'error',cache:'no-store',signal,
  19. headers:{Authorization:`Bearer ${config.token}`,'x-desktop-host-protocol':'1','content-type':'application/json'},body:JSON.stringify(body)})
  20. if(response.status!==200){await response.body?.cancel();checkExchangeStatus(response.status)}
  21. const reader=response.body?.getReader()
  22. if(!reader)throw new Error('电脑响应为空')
  23. let size=0;const chunks=[]
  24. try{
  25. while(true){
  26. const {done,value}=await reader.read()
  27. if(done)break
  28. size+=value.byteLength
  29. if(size>16*1024*1024){await reader.cancel();throw new Error('桌面命令超过限制')}
  30. chunks.push(value)
  31. }
  32. }finally{reader.releaseLock()}
  33. return JSON.parse(Buffer.concat(chunks).toString())
  34. }catch(error){if(signal.aborted)throw new Error('电脑连接超时');throw error}
  35. }
  36. export function remoteExchange(config,body,fetchImpl){
  37. if(fetchImpl)return fetchExchange(config,body,fetchImpl)
  38. return new Promise((resolve,reject)=>{
  39. const url=new URL(`/api/desktop-host/v1/${config.hostId}/exchange`,config.serverURL)
  40. if(!['http:','https:'].includes(url.protocol))return reject(new Error('夭夭服务地址无效'))
  41. const bytes=Buffer.from(JSON.stringify(body)),send=url.protocol==='https'?httpsRequest:httpRequest
  42. 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=>{
  43. let size=0;const chunks=[]
  44. res.on('data',chunk=>{size+=chunk.length;if(size>16*1024*1024)res.destroy(new Error('桌面命令超过限制'));else chunks.push(chunk)})
  45. res.on('error',reject)
  46. res.on('end',()=>{try{
  47. checkExchangeStatus(res.statusCode)
  48. resolve(JSON.parse(Buffer.concat(chunks).toString()))
  49. }catch(error){reject(error)}})
  50. })
  51. req.on('error',reject)
  52. req.setTimeout(10000,()=>req.destroy(new Error('电脑连接超时')))
  53. req.end(bytes)
  54. })}
  55. /** Remote computer: imports an encrypted pairing config and exchanges
  56. * commands with the paired server. Native-only; no browser IPC. */
  57. export class DesktopHostManager {
  58. constructor(options){
  59. if(typeof options?.createCore!=='function')throw new Error('电脑需要命令执行核心')
  60. this.options=options
  61. this.path=join(options.home,'desktop-host.enc')
  62. this.createCore=options.createCore
  63. this.state='未配置电脑'
  64. this.core=undefined
  65. this.config=undefined
  66. this.timer=undefined
  67. this.backoff=400
  68. this.chain=Promise.resolve()
  69. this.generation=0
  70. }
  71. publish(message){this.state=message;this.options.onState?.(message)}
  72. async installIdentity(){
  73. const path=join(this.options.home,'desktop-install-id')
  74. const readIdentity=async()=>{
  75. const id=(await readFile(path,'utf8')).trim()
  76. if(!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id))throw new Error('本机设备标识损坏,请恢复原设备标识后重试')
  77. return id
  78. }
  79. try{return await readIdentity()}catch(error){if(error.code!=='ENOENT')throw error}
  80. const id=randomUUID()
  81. await mkdir(this.options.home,{recursive:true,mode:0o700})
  82. const temporary=`${path}.${id}`
  83. try{
  84. // Publish a complete identity without overwriting a concurrent login's ID.
  85. await writeFile(temporary,id,{flag:'wx',mode:0o600})
  86. try{await link(temporary,path);return id}catch(error){if(error.code!=='EEXIST')throw error}
  87. return await readIdentity()
  88. }finally{await rm(temporary,{force:true})}
  89. }
  90. serial(work){const result=this.chain.catch(()=>{}).then(work);this.chain=result;return result}
  91. async read(){
  92. let encrypted
  93. try{encrypted=await readFile(this.path)}catch(error){if(error.code==='ENOENT')return;throw error}
  94. try{return parseDesktopHostConfiguration(JSON.parse(await this.options.decrypt(encrypted)))}
  95. catch{throw new Error('无法解锁电脑配置,请使用原系统账号或重新登录授权')}
  96. }
  97. importFile(path){
  98. const generation=++this.generation
  99. return this.serial(async()=>{
  100. let config
  101. try{config=parseDesktopHostConfiguration(JSON.parse(await readFile(path,'utf8')))}
  102. catch(error){throw error instanceof Error?error:new Error('电脑配置无效,请从夭夭重新下载配置')}
  103. await this.store(config,generation)
  104. })
  105. }
  106. /** Adopt a config obtained in-process (e.g. after a native remote login). */
  107. adopt(value){
  108. const generation=++this.generation
  109. return this.serial(async()=>{
  110. const config=parseDesktopHostConfiguration(value)
  111. await this.store(config,generation)
  112. })
  113. }
  114. async store(config,generation){
  115. // Encrypt before stopping a working host: failed Keychain access preserves it.
  116. if(generation!==this.generation)return
  117. const bytes=await this.options.encrypt(JSON.stringify(config))
  118. if(generation!==this.generation)return
  119. await this.stopLoop()
  120. if(generation!==this.generation)return
  121. await mkdir(this.options.home,{recursive:true,mode:0o700})
  122. const temporary=`${this.path}.${randomUUID()}`
  123. try{await writeFile(temporary,bytes,{flag:'wx',mode:0o600});await rename(temporary,this.path)}
  124. finally{await rm(temporary,{force:true})}
  125. if(generation!==this.generation)return
  126. await this.startLoop(config)
  127. }
  128. 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)})}
  129. async startLoop(config){
  130. await this.stopLoop()
  131. this.config=config
  132. this.core=this.createCore({root:this.options.root,dataRoot:this.options.dataRoot})
  133. this.backoff=400
  134. this.publish(`电脑正在连接 ${new URL(config.serverURL).host}`)
  135. this.timer=setTimeout(()=>void this.cycle(),400)
  136. this.timer.unref?.()
  137. }
  138. async cycle(){
  139. const config=this.config,core=this.core
  140. if(!config||!core||this.core!==core)return
  141. try{
  142. await core.prepareInfo?.()
  143. if(this.config!==config||this.core!==core)return
  144. const value=await remoteExchange(config,{host:core.info(),results:core.takeResults()},this.options.fetchImpl)
  145. this.backoff=400
  146. if(this.config!==config||this.core!==core)return
  147. this.publish(process.platform==='win32'&&!(Array.isArray(value.capabilities?.desktopPlatforms)&&value.capabilities.desktopPlatforms.includes('win32'))
  148. ? '已连接;服务器尚不支持 Windows 电脑操作,请升级服务器(聊天可继续使用)'
  149. : `电脑已连接 ${new URL(config.serverURL).host}`)
  150. core.acceptCapabilities?.(value.capabilities)
  151. await core.handle(value.commands??[])
  152. }catch(error){
  153. if(this.config!==config||this.core!==core)return
  154. core.acceptCapabilities?.(undefined)
  155. if(error&&error[FATAL]){this.publish(error.message);await this.stopLoop();return}
  156. this.backoff=Math.min(this.backoff*2,5000)
  157. this.publish(`电脑连接中断,正在重试:${String(error.message).slice(0,120)}`)
  158. }finally{
  159. if(this.core===core){this.timer=setTimeout(()=>void this.cycle(),this.backoff);this.timer.unref?.()}
  160. }
  161. }
  162. async stopLoop(){
  163. clearTimeout(this.timer);this.timer=undefined
  164. const core=this.core
  165. this.core=undefined;this.config=undefined
  166. if(core)await core.close().catch(()=>{})
  167. }
  168. stop(){this.generation++;return this.serial(()=>this.stopLoop())}
  169. forget(){this.generation++;return this.serial(async()=>{await this.stopLoop();await rm(this.path,{force:true});this.publish('未配置电脑')})}
  170. revoke(){return this.serial(async()=>{await this.core?.revoke()})}
  171. }