runner-manager.mjs 5.0 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798
  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(await 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 = await this.options.encrypt(JSON.stringify(config))
  33. if(generation!==this.generation)return
  34. await this.stopChild()
  35. if(generation!==this.generation)return
  36. await mkdir(this.options.home, { recursive: true, mode: 0o700 })
  37. const temporary = `${this.path}.${randomUUID()}`
  38. try { await writeFile(temporary, bytes, { flag: 'wx', mode: 0o600 }); await rename(temporary, this.path) }
  39. finally { await rm(temporary, { force: true }) }
  40. if(generation!==this.generation)return
  41. this.stopping = false; this.restarts = []
  42. await this.startChild(config)
  43. })
  44. }
  45. 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) }) }
  46. async startChild(config) {
  47. if(this.child || this.stopping) return
  48. const child = this.options.fork()
  49. this.child = child
  50. this.stopFailure = undefined
  51. // Drain diagnostics without persisting possible upstream or parser secrets.
  52. child.stdout?.resume();child.stderr?.resume()
  53. this.publish('执行节点正在连接')
  54. child.once('spawn', () => {
  55. if(this.child !== child || this.stopping) { child.postMessage({type:'shutdown'}); return }
  56. child.postMessage({ type: 'configure', config, home: join(this.options.home, 'runner-state') })
  57. })
  58. child.on('message', message => {
  59. if(this.child !== child) return
  60. if(['status','error'].includes(message?.type) && typeof message.message === 'string') this.publish(message.message.slice(0,200))
  61. })
  62. child.exited = new Promise(resolve => child.once('exit', code => {
  63. child.finished = true;child.exitCode=code
  64. if(this.stopping&&code!==0)this.stopFailure=new Error('执行节点异常退出,电脑停止状态待确认')
  65. resolve()
  66. if(this.child !== child) return
  67. this.child = null
  68. if(this.stopping) return
  69. if(code === 0) { this.publish('执行节点已停止'); return }
  70. this.restarts = this.restarts.filter(time => Date.now()-time < 60000)
  71. if(this.restarts.length >= 3) { this.publish('执行节点反复退出,请检查配置后重连'); return }
  72. this.restarts.push(Date.now())
  73. this.publish('执行节点意外退出,正在重连')
  74. this.restartTimer = setTimeout(() => { void this.start().catch(() => this.publish('无法读取执行节点配置,请重新导入')) },1000)
  75. this.restartTimer.unref()
  76. }))
  77. }
  78. async stopChild() {
  79. this.stopping = true; clearTimeout(this.restartTimer)
  80. const child = this.child
  81. if(!child){if(this.stopFailure)throw this.stopFailure;return}
  82. child.postMessage({type:'shutdown'})
  83. let timer
  84. try { await Promise.race([child.exited,new Promise(resolve => { timer = setTimeout(resolve,30000) })]) }
  85. finally { clearTimeout(timer) }
  86. if(!child.finished) {
  87. child.kill()
  88. try { await Promise.race([child.exited,new Promise(resolve => { timer = setTimeout(resolve,5000) })]) }
  89. finally { clearTimeout(timer) }
  90. }
  91. if(!child.finished) throw new Error('执行节点尚未确认退出,暂不启动替代进程')
  92. if(this.child === child) this.child = null
  93. if(this.stopFailure)throw this.stopFailure
  94. }
  95. stop() { this.generation++;this.stopping = true; clearTimeout(this.restartTimer); return this.serial(() => this.stopChild()) }
  96. forget() { this.generation++;this.stopping = true; return this.serial(async () => { await this.stopChild(); await rm(this.path,{force:true}); this.publish('未配置执行节点') }) }
  97. }