runner-manager.mjs 4.7 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394
  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. // Drain diagnostics without persisting possible upstream or parser secrets.
  50. child.stdout?.resume();child.stderr?.resume()
  51. this.publish('执行节点正在连接')
  52. child.once('spawn', () => {
  53. if(this.child !== child || this.stopping) { child.postMessage({type:'shutdown'}); return }
  54. child.postMessage({ type: 'configure', config, home: join(this.options.home, 'runner-state') })
  55. })
  56. child.on('message', message => {
  57. if(this.child !== child) return
  58. if(['status','error'].includes(message?.type) && typeof message.message === 'string') this.publish(message.message.slice(0,200))
  59. })
  60. child.exited = new Promise(resolve => child.once('exit', code => {
  61. child.finished = true
  62. resolve()
  63. if(this.child !== child) return
  64. this.child = null
  65. if(this.stopping) return
  66. if(code === 0) { this.publish('执行节点已停止'); return }
  67. this.restarts = this.restarts.filter(time => Date.now()-time < 60000)
  68. if(this.restarts.length >= 3) { this.publish('执行节点反复退出,请检查配置后重连'); return }
  69. this.restarts.push(Date.now())
  70. this.publish('执行节点意外退出,正在重连')
  71. this.restartTimer = setTimeout(() => { void this.start().catch(() => this.publish('无法读取执行节点配置,请重新导入')) },1000)
  72. this.restartTimer.unref()
  73. }))
  74. }
  75. async stopChild() {
  76. this.stopping = true; clearTimeout(this.restartTimer)
  77. const child = this.child
  78. if(!child) return
  79. child.postMessage({type:'shutdown'})
  80. let timer
  81. try { await Promise.race([child.exited,new Promise(resolve => { timer = setTimeout(resolve,5000) })]) }
  82. finally { clearTimeout(timer) }
  83. if(!child.finished) {
  84. child.kill()
  85. try { await Promise.race([child.exited,new Promise(resolve => { timer = setTimeout(resolve,5000) })]) }
  86. finally { clearTimeout(timer) }
  87. }
  88. if(!child.finished) throw new Error('执行节点尚未确认退出,暂不启动替代进程')
  89. if(this.child === child) this.child = null
  90. }
  91. stop() { this.generation++;this.stopping = true; clearTimeout(this.restartTimer); return this.serial(() => this.stopChild()) }
  92. forget() { this.generation++;this.stopping = true; return this.serial(async () => { await this.stopChild(); await rm(this.path,{force:true}); this.publish('未配置执行节点') }) }
  93. }