service-manager.mjs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300
  1. import { readFile, realpath, mkdir } from 'node:fs/promises'
  2. import { join } from 'node:path'
  3. import { createHash } from 'node:crypto'
  4. import { request as httpRequest } from 'node:http'
  5. import { request as httpsRequest } from 'node:https'
  6. import { execFile } from 'node:child_process'
  7. import { promisify } from 'node:util'
  8. import { deferDesktopActivation } from './desktop-activation.mjs'
  9. const exec = promisify(execFile)
  10. const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
  11. export const dataKey = root => createHash('sha256').update(root).digest('hex')
  12. export function localJSON(url, headers = {}, method = 'GET', timeout = 1500) {
  13. const target = new URL(url)
  14. if (!['http:', 'https:'].includes(target.protocol) || !['127.0.0.1', '[::1]'].includes(target.hostname) || target.username || target.password)
  15. return Promise.reject(new Error('桌面服务必须使用本机回环地址。'))
  16. return new Promise((resolve, reject) => {
  17. const req = (target.protocol === 'https:' ? httpsRequest : httpRequest)(target, { method, headers, agent: false }, res => {
  18. let body = '', bytes = 0
  19. res.on('data', chunk => {
  20. bytes += chunk.length
  21. if (bytes > 64 * 1024) req.destroy(new Error('服务身份响应过大'))
  22. else body += chunk
  23. })
  24. res.on('end', () => {
  25. if (res.statusCode !== 200) { reject(new Error(`服务返回 HTTP ${res.statusCode}`)); return }
  26. try { resolve(JSON.parse(body)) } catch { reject(new Error('服务返回了无效响应')) }
  27. })
  28. })
  29. req.setTimeout(timeout, () => req.destroy(new Error('服务连接超时')))
  30. req.on('error', reject); req.end()
  31. })
  32. }
  33. function isAlive(pid) {
  34. if (!Number.isSafeInteger(pid) || pid < 1) return false
  35. try { process.kill(pid, 0); return true } catch (e) { return e.code !== 'ESRCH' }
  36. }
  37. /** One owner for a packaged service child. Existing services are connected to,
  38. * never stopped. All callbacks are fenced to their exact child generation. */
  39. export class DesktopServiceManager {
  40. constructor(options) {
  41. this.options = options
  42. this.state = { phase: 'stopped', message: '本地服务尚未启动' }
  43. this.child = null
  44. this.generation = 0
  45. this.stopping = false
  46. this.restarts = []
  47. this.activationRequested = false
  48. }
  49. publish(value) {
  50. if (value.phase === 'ready' && this.updateNotice) value = { ...value, updateNotice: this.updateNotice, message: `${value.message},Web 同步待完成` }
  51. this.state = value; this.options.onState?.(value)
  52. }
  53. get canForceSynchronization() {
  54. return Boolean(this.options.synchronize) && (this.state.canForceSync === true || this.state.phase === 'ready' && this.state.version === this.options.version)
  55. }
  56. async retrySynchronization({ force = false } = {}) {
  57. if (this.starting) return this.starting
  58. if (force && !this.canForceSynchronization) throw new Error('当前状态不能覆盖同版本 Web')
  59. this.state = { phase: 'stopped', message: '正在准备同步 Web…' }
  60. return this.start({ force })
  61. }
  62. async reconnect() {
  63. const generation=this.generation
  64. if (this.state.phase === 'ready' && !this.stopping) {
  65. await this.check()
  66. if(this.stopping||generation!==this.generation)return this.state
  67. }
  68. if (this.state.phase === 'ready' && !this.stopping) return this.state
  69. clearTimeout(this.restartTimer)
  70. return this.start()
  71. }
  72. async readRecord() {
  73. try {
  74. const r = JSON.parse(await readFile(join(this.root, 'service-instance.json'), 'utf8'))
  75. return r.protocol === 1 && r.dataKey === dataKey(this.root) && isAlive(r.pid) ? r : null
  76. } catch { return null }
  77. }
  78. async verify(record) {
  79. if (!record.url) throw new Error('服务尚未监听')
  80. const url = new URL(record.url)
  81. if (url.hostname === '0.0.0.0') url.hostname = '127.0.0.1'
  82. const actual = await localJSON(new URL('/desktop/service', url), { 'x-yaoyao-desktop-token': record.token })
  83. if (actual.protocol !== 1 || actual.instanceId !== record.instanceId || actual.pid !== record.pid || actual.dataKey !== dataKey(this.root) || actual.version !== record.version || typeof actual.version !== 'string' || !/^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][A-Za-z0-9.-]+)?$/.test(actual.version))
  84. throw new Error('服务身份与本地数据不匹配')
  85. if (this.child?.pid === actual.pid && actual.version !== this.options.version) throw new Error('App 内置服务版本不匹配,请重新安装完整 App')
  86. return { ...actual, url: url.origin }
  87. }
  88. async activate() {
  89. if (this.state.legacy) { this.activationRequested = true; return }
  90. const generation = this.generation, record = await this.readRecord()
  91. if (!record) throw new Error('本机服务尚未就绪')
  92. const service = await this.verify(record)
  93. if (this.stopping || generation !== this.generation) throw new Error('本机服务状态已改变,请重试')
  94. await this.activateRecord(record, service)
  95. if (this.stopping || generation !== this.generation) throw new Error('本机服务状态已改变,请重试')
  96. this.activationRequested = true
  97. }
  98. async adminSession() {
  99. const generation = this.generation, record = await this.readRecord()
  100. if (!record) throw new Error('请更新本机 Web 服务以启用管理员免登录')
  101. const service = await this.verify(record)
  102. if (this.stopping || generation !== this.generation) throw new Error('本机服务状态已改变,请重试')
  103. const session = await localJSON(new URL('/desktop/service/session', service.url), { 'x-yaoyao-desktop-token': record.token }, 'POST')
  104. if (this.stopping || generation !== this.generation) throw new Error('本机服务状态已改变,请重试')
  105. return session
  106. }
  107. async activateRecord(record, service) {
  108. if (service.activationRequired)
  109. await localJSON(new URL('/desktop/service/activate', service.url), { 'x-yaoyao-desktop-token': record.token }, 'POST')
  110. }
  111. async legacyService() {
  112. // Old CLI/LaunchAgent builds lack the new descriptor. Prove the listener
  113. // actually holds this directory's SQLite file before reusing its own UI.
  114. if (process.platform !== 'darwin') return null
  115. const pids = async args => {
  116. try { return new Set((await exec('/usr/sbin/lsof', args, { timeout: 3000 })).stdout.trim().split(/\s+/).filter(Boolean)) }
  117. catch { return new Set() }
  118. }
  119. const [holders, listeners] = await Promise.all([
  120. pids(['-t', join(this.root, 'workspace.sqlite3')]),
  121. pids(['-nP', `-iTCP:${this.options.port}`, '-sTCP:LISTEN', '-t']),
  122. ])
  123. if (!holders.size) return null
  124. if (holders.size !== 1 || !listeners.has([...holders][0])) throw new Error('数据目录正在被另一个服务使用,请先确认已有服务。')
  125. const url = `http://127.0.0.1:${this.options.port}`
  126. const status = await localJSON(new URL('/api/status', url))
  127. if (status.server_kind !== 'yaoyao-web') throw new Error('已有端口不是夭夭服务。')
  128. return { url, pid: Number([...holders][0]), external: true, legacy: true, version: '已有服务' }
  129. }
  130. async start({ force = false } = {}) {
  131. if (this.starting) return this.starting
  132. if (this.state.phase === 'ready' && !this.stopping) return this.state
  133. this.stopping = false
  134. this.starting = this.boot({ force }).finally(() => { this.starting = null })
  135. return this.starting
  136. }
  137. async boot({ force = false } = {}) {
  138. const generation = ++this.generation
  139. this.publish({ phase: 'starting', stage: 'checking', message: '正在检查本地服务…' })
  140. try {
  141. await this.options.prepareHome?.(message => this.publish({ phase: 'starting', stage: 'checking', message }))
  142. await mkdir(this.options.home, { recursive: true, mode: 0o700 })
  143. this.root = await realpath(this.options.home)
  144. this.updateNotice = undefined
  145. if (this.options.synchronize) {
  146. try { await this.options.synchronize(message => this.publish({ phase: 'starting', stage: 'installing', message }), { force }) }
  147. catch (error) {
  148. // Busy is reported before stopping or switching. Other failures still
  149. // fail closed; the existing service must pass the usual identity checks.
  150. if (error.code !== 'service_busy') throw error
  151. this.updateNotice = error.message
  152. }
  153. if (generation !== this.generation || this.stopping) return
  154. }
  155. this.publish({ phase: 'starting', stage: 'starting', message: '正在启动并检查服务…' })
  156. let record = await this.readRecord()
  157. if (generation !== this.generation || this.stopping) return
  158. if (record?.url) {
  159. const service = await this.verify(record)
  160. if (generation !== this.generation || this.stopping) return
  161. if (this.activationRequested) await this.activateRecord(record, service)
  162. if (generation !== this.generation || this.stopping) return
  163. this.publish({ phase: 'ready', message: `已连接已有服务(${service.version})`, ...service, external: !this.child })
  164. return this.state
  165. }
  166. const legacy = !record ? await this.legacyService() : null
  167. if (generation !== this.generation || this.stopping) return
  168. if (legacy) { this.publish({ phase: 'ready', message: '已连接已有后台服务', ...legacy }); return this.state }
  169. if (!record) {
  170. if (this.options.synchronize) throw new Error('独立后台服务尚未就绪,请稍后重试')
  171. deferDesktopActivation(this.root)
  172. const child = this.options.fork({ home: this.root, port: this.options.port })
  173. child.desktopExited = false
  174. this.child = child
  175. child.stdout?.on('data', b => this.options.log?.(String(b)))
  176. child.stderr?.on('data', b => this.options.log?.(String(b)))
  177. child.once('exit', code => {
  178. child.desktopExited = true
  179. if (this.child !== child) return
  180. this.child = null
  181. if (this.stopping || this.state.phase === 'starting') return
  182. this.options.log?.(`服务进程退出:${code}`)
  183. this.scheduleRestart(this.generation)
  184. })
  185. }
  186. const deadline = Date.now() + (this.options.timeoutMs ?? 30_000)
  187. let lastError
  188. while (Date.now() < deadline && !this.stopping && generation === this.generation) {
  189. record = await this.readRecord()
  190. if (record?.url) {
  191. try {
  192. const service = await this.verify(record)
  193. if (this.child && service.pid !== this.child.pid) throw new Error('服务启动期间归属发生变化')
  194. if (generation !== this.generation || this.stopping) return
  195. if (this.activationRequested) await this.activateRecord(record, service)
  196. if (generation !== this.generation || this.stopping) return
  197. this.publish({ phase: 'ready', message: '本地服务已就绪', ...service, external: !this.child })
  198. return this.state
  199. } catch (e) { lastError = e }
  200. }
  201. if (!this.child && !record) throw new Error('服务进程未能启动,请查看日志后重试。')
  202. await sleep(100)
  203. }
  204. if (!this.stopping) throw new Error(lastError?.message || '服务启动超时,请查看日志后重试。')
  205. } catch (error) {
  206. if (generation !== this.generation || this.stopping) return
  207. await this.stopChild()
  208. this.publish({ phase: 'error', message: error.message, canForceSync: error.code === 'service_build_unknown' })
  209. throw error
  210. }
  211. }
  212. scheduleRestart(generation) {
  213. const now = Date.now()
  214. this.restarts = this.restarts.filter(time => now - time < 5 * 60_000)
  215. if (this.restarts.length >= (this.options.synchronize ? 12 : 3)) { this.publish({ phase: 'error', message: '服务多次断开,请查看日志后重试。' }); return }
  216. this.restarts.push(now)
  217. this.publish({ phase: 'restarting', message: '服务已断开,正在恢复…' })
  218. this.restartTimer = setTimeout(() => {
  219. if (this.stopping || generation !== this.generation) return
  220. void this.start().catch(() => { if (this.options.synchronize && !this.stopping) this.scheduleRestart(this.generation) })
  221. }, this.options.synchronize ? 5000 : 500 * this.restarts.length)
  222. }
  223. async check() {
  224. if (this.state.phase !== 'ready') return
  225. const generation = this.generation
  226. try {
  227. if (this.state.legacy) {
  228. if (!isAlive(this.state.pid)) throw new Error('已有服务已停止')
  229. await localJSON(new URL('/api/status', this.state.url))
  230. } else {
  231. const record = await this.readRecord()
  232. if (!record) throw new Error('服务已停止')
  233. const service = await this.verify(record)
  234. if (this.stopping || generation !== this.generation) return
  235. if (this.activationRequested) await this.activateRecord(record, service)
  236. if (this.stopping || generation !== this.generation) return
  237. if (service.pid !== this.state.pid || service.version !== this.state.version || service.build?.commit !== this.state.build?.commit)
  238. this.publish({ phase: 'ready', message: `已连接后台服务(${service.version})`, ...service, external: !this.child })
  239. }
  240. } catch {
  241. if (this.stopping || generation !== this.generation) return
  242. if (this.state.external) this.scheduleRestart(generation)
  243. else this.publish({ phase: 'error', message: '本地服务连接已断开,请重试。' })
  244. }
  245. }
  246. async stopChild() {
  247. const child = this.child
  248. if (!child || child.desktopExited) return
  249. const exited = new Promise(resolve => child.once('exit', resolve))
  250. const record = await this.readRecord()
  251. if (record?.pid === child.pid && record.desktopOwned) {
  252. try { await localJSON(new URL('/desktop/service', record.url), { 'x-yaoyao-desktop-token': record.token }, 'DELETE') }
  253. catch { /* Owned process handle remains authoritative for cleanup. */ }
  254. } else child.kill()
  255. let timer
  256. await Promise.race([exited, new Promise(resolve => { timer = setTimeout(() => { child.kill(); resolve() }, 5000) })])
  257. clearTimeout(timer)
  258. if (!child.desktopExited) {
  259. await Promise.race([exited, sleep(2000)])
  260. if (!child.desktopExited && child.pid) {
  261. try { process.kill(child.pid, 'SIGKILL') } catch (error) { if (error.code !== 'ESRCH') throw error }
  262. await Promise.race([exited, sleep(2000)])
  263. }
  264. if (!child.desktopExited) throw new Error('旧服务进程尚未确认退出,暂不启动替代进程。')
  265. }
  266. if (this.child === child) this.child = null
  267. }
  268. async stop() {
  269. const owned = Boolean(this.child)
  270. this.stopping = true
  271. this.activationRequested = false
  272. clearTimeout(this.restartTimer)
  273. ++this.generation
  274. await this.starting?.catch(() => {})
  275. await this.stopChild()
  276. this.publish(owned ? { phase: 'stopped', message: '后台服务已停止' }
  277. : { phase: 'disconnected', message: 'App 已断开后台连接' })
  278. }
  279. async stopBackground() {
  280. this.stopping = true
  281. this.activationRequested = false
  282. clearTimeout(this.restartTimer)
  283. ++this.generation
  284. await this.starting?.catch(() => {})
  285. this.publish({phase:'stopping',message:'正在停止后台服务…'})
  286. try {
  287. if(this.options.stopBackground)await this.options.stopBackground(message=>this.publish({phase:'stopping',message}))
  288. else if(this.child)await this.stopChild()
  289. else throw new Error('此后台服务不由本 App 管理,请在它的启动位置停止')
  290. this.publish({phase:'stopped',message:'后台服务已停止'})
  291. } catch(error) {
  292. this.publish({phase:'error',message:'后台服务停止未完成,请重新连接检查'})
  293. throw error
  294. }
  295. }
  296. }