runner-manager.mjs 5.0 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697
  1. import { readFile, writeFile, rename, mkdir, rm } from 'node:fs/promises'
  2. import { join } from 'node:path'
  3. import { randomUUID } from 'node:crypto'
  4. /** Native-only credentials and child ownership. No browser IPC exposes this class. */
  5. export class DesktopRunnerManager {
  6. constructor(options) {
  7. this.options = options
  8. this.path = join(options.home, 'desktop-runner.enc')
  9. this.state = '未配置执行节点'
  10. this.child = null
  11. this.chain = Promise.resolve()
  12. this.stopping = false
  13. this.generation = 0
  14. this.restarts = []
  15. }
  16. publish(message) { this.state = message; this.options.onState?.(message) }
  17. serial(work) { const result = this.chain.catch(() => {}).then(work); this.chain = result; return result }
  18. async read() {
  19. let encrypted
  20. try { encrypted = await readFile(this.path) } catch(error) { if(error.code === 'ENOENT') return; throw error }
  21. try { return this.options.validate(JSON.parse(this.options.decrypt(encrypted))) }
  22. catch { throw new Error('无法解锁执行节点配置,请检查钥匙串或重新导入') }
  23. }
  24. importFile(path) {
  25. const generation=++this.generation
  26. return this.serial(async () => {
  27. let config
  28. try { config = this.options.validate(JSON.parse(await readFile(path, 'utf8'))) }
  29. catch { throw new Error('执行节点配置无效,请从夭夭重新下载配置') }
  30. // Encrypt before stopping a working node: failed Keychain access preserves it.
  31. if(generation!==this.generation)return
  32. const bytes = this.options.encrypt(JSON.stringify(config))
  33. await this.stopChild()
  34. if(generation!==this.generation)return
  35. await mkdir(this.options.home, { recursive: true, mode: 0o700 })
  36. const temporary = `${this.path}.${randomUUID()}`
  37. try { await writeFile(temporary, bytes, { flag: 'wx', mode: 0o600 }); await rename(temporary, this.path) }
  38. finally { await rm(temporary, { force: true }) }
  39. if(generation!==this.generation)return
  40. this.stopping = false; this.restarts = []
  41. await this.startChild(config)
  42. })
  43. }
  44. start() { const generation=++this.generation; return this.serial(async () => { const config=await this.read();if(generation!==this.generation)return;this.stopping=false;if(config)await this.startChild(config) }) }
  45. async startChild(config) {
  46. if(this.child || this.stopping) return
  47. const child = this.options.fork()
  48. this.child = child
  49. this.stopFailure = undefined
  50. // Drain diagnostics without persisting possible upstream or parser secrets.
  51. child.stdout?.resume();child.stderr?.resume()
  52. this.publish('执行节点正在连接')
  53. child.once('spawn', () => {
  54. if(this.child !== child || this.stopping) { child.postMessage({type:'shutdown'}); return }
  55. child.postMessage({ type: 'configure', config, home: join(this.options.home, 'runner-state') })
  56. })
  57. child.on('message', message => {
  58. if(this.child !== child) return
  59. if(['status','error'].includes(message?.type) && typeof message.message === 'string') this.publish(message.message.slice(0,200))
  60. })
  61. child.exited = new Promise(resolve => child.once('exit', code => {
  62. child.finished = true;child.exitCode=code
  63. if(this.stopping&&code!==0)this.stopFailure=new Error('执行节点异常退出,电脑停止状态待确认')
  64. resolve()
  65. if(this.child !== child) return
  66. this.child = null
  67. if(this.stopping) return
  68. if(code === 0) { this.publish('执行节点已停止'); return }
  69. this.restarts = this.restarts.filter(time => Date.now()-time < 60000)
  70. if(this.restarts.length >= 3) { this.publish('执行节点反复退出,请检查配置后重连'); return }
  71. this.restarts.push(Date.now())
  72. this.publish('执行节点意外退出,正在重连')
  73. this.restartTimer = setTimeout(() => { void this.start().catch(() => this.publish('无法读取执行节点配置,请重新导入')) },1000)
  74. this.restartTimer.unref()
  75. }))
  76. }
  77. async stopChild() {
  78. this.stopping = true; clearTimeout(this.restartTimer)
  79. const child = this.child
  80. if(!child){if(this.stopFailure)throw this.stopFailure;return}
  81. child.postMessage({type:'shutdown'})
  82. let timer
  83. try { await Promise.race([child.exited,new Promise(resolve => { timer = setTimeout(resolve,30000) })]) }
  84. finally { clearTimeout(timer) }
  85. if(!child.finished) {
  86. child.kill()
  87. try { await Promise.race([child.exited,new Promise(resolve => { timer = setTimeout(resolve,5000) })]) }
  88. finally { clearTimeout(timer) }
  89. }
  90. if(!child.finished) throw new Error('执行节点尚未确认退出,暂不启动替代进程')
  91. if(this.child === child) this.child = null
  92. if(this.stopFailure)throw this.stopFailure
  93. }
  94. stop() { this.generation++;this.stopping = true; clearTimeout(this.restartTimer); return this.serial(() => this.stopChild()) }
  95. forget() { this.generation++;this.stopping = true; return this.serial(async () => { await this.stopChild(); await rm(this.path,{force:true}); this.publish('未配置执行节点') }) }
  96. }