| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205 |
- import { readFile, realpath, mkdir } from 'node:fs/promises'
- import { join } from 'node:path'
- import { createHash } from 'node:crypto'
- import { request as httpRequest } from 'node:http'
- import { request as httpsRequest } from 'node:https'
- import { execFile } from 'node:child_process'
- import { promisify } from 'node:util'
- const exec = promisify(execFile)
- const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
- export const dataKey = root => createHash('sha256').update(root).digest('hex')
- export function localJSON(url, headers = {}, method = 'GET', timeout = 1500) {
- const target = new URL(url)
- if (!['http:', 'https:'].includes(target.protocol) || !['127.0.0.1', '[::1]'].includes(target.hostname) || target.username || target.password)
- return Promise.reject(new Error('桌面服务必须使用本机回环地址。'))
- return new Promise((resolve, reject) => {
- const req = (target.protocol === 'https:' ? httpsRequest : httpRequest)(target, { method, headers, agent: false }, res => {
- let body = '', bytes = 0
- res.on('data', chunk => {
- bytes += chunk.length
- if (bytes > 64 * 1024) req.destroy(new Error('服务身份响应过大'))
- else body += chunk
- })
- res.on('end', () => {
- if (res.statusCode !== 200) { reject(new Error(`服务返回 HTTP ${res.statusCode}`)); return }
- try { resolve(JSON.parse(body)) } catch { reject(new Error('服务返回了无效响应')) }
- })
- })
- req.setTimeout(timeout, () => req.destroy(new Error('服务连接超时')))
- req.on('error', reject); req.end()
- })
- }
- function isAlive(pid) {
- if (!Number.isSafeInteger(pid) || pid < 1) return false
- try { process.kill(pid, 0); return true } catch (e) { return e.code !== 'ESRCH' }
- }
- /** One owner for a packaged service child. Existing services are connected to,
- * never stopped. All callbacks are fenced to their exact child generation. */
- export class DesktopServiceManager {
- constructor(options) {
- this.options = options
- this.state = { phase: 'stopped', message: '本地服务尚未启动' }
- this.child = null
- this.generation = 0
- this.stopping = false
- this.restarts = []
- }
- publish(value) { this.state = value; this.options.onState?.(value) }
- async readRecord() {
- try {
- const r = JSON.parse(await readFile(join(this.root, 'service-instance.json'), 'utf8'))
- return r.protocol === 1 && r.dataKey === dataKey(this.root) && isAlive(r.pid) ? r : null
- } catch { return null }
- }
- async verify(record) {
- if (!record.url) throw new Error('服务尚未监听')
- const url = new URL(record.url)
- if (url.hostname === '0.0.0.0') url.hostname = '127.0.0.1'
- const actual = await localJSON(new URL('/desktop/service', url), { 'x-yaoyao-desktop-token': record.token })
- if (actual.protocol !== 1 || actual.instanceId !== record.instanceId || actual.pid !== record.pid || actual.dataKey !== dataKey(this.root))
- throw new Error('服务身份与本地数据不匹配')
- return { ...actual, url: url.origin }
- }
- async legacyService() {
- // Old CLI/LaunchAgent builds lack the new descriptor. Prove the listener
- // actually holds this directory's SQLite file before reusing its own UI.
- if (process.platform !== 'darwin') return null
- const pids = async args => {
- try { return new Set((await exec('/usr/sbin/lsof', args, { timeout: 3000 })).stdout.trim().split(/\s+/).filter(Boolean)) }
- catch { return new Set() }
- }
- const [holders, listeners] = await Promise.all([
- pids(['-t', join(this.root, 'workspace.sqlite3')]),
- pids(['-nP', `-iTCP:${this.options.port}`, '-sTCP:LISTEN', '-t']),
- ])
- if (!holders.size) return null
- if (holders.size !== 1 || !listeners.has([...holders][0])) throw new Error('数据目录正在被另一个服务使用,请先确认已有服务。')
- const url = `http://127.0.0.1:${this.options.port}`
- const status = await localJSON(new URL('/api/status', url))
- if (status.server_kind !== 'yaoyao-web') throw new Error('已有端口不是夭夭服务。')
- return { url, pid: Number([...holders][0]), external: true, legacy: true, version: '已有服务' }
- }
- async start() {
- if (this.starting) return this.starting
- if (this.state.phase === 'ready') return this.state
- this.stopping = false
- this.starting = this.boot().finally(() => { this.starting = null })
- return this.starting
- }
- async boot() {
- const generation = ++this.generation
- this.publish({ phase: 'starting', message: '正在启动本地服务…' })
- try {
- await mkdir(this.options.home, { recursive: true, mode: 0o700 })
- this.root = await realpath(this.options.home)
- let record = await this.readRecord()
- if (generation !== this.generation || this.stopping) return
- if (record?.url) {
- const service = await this.verify(record)
- if (generation !== this.generation || this.stopping) return
- this.publish({ phase: 'ready', message: '已连接已有服务', ...service, external: !this.child })
- return this.state
- }
- const legacy = !record ? await this.legacyService() : null
- if (generation !== this.generation || this.stopping) return
- if (legacy) { this.publish({ phase: 'ready', message: '已连接已有后台服务', ...legacy }); return this.state }
- if (!record) {
- const child = this.options.fork({ home: this.root, port: this.options.port })
- child.desktopExited = false
- this.child = child
- child.stdout?.on('data', b => this.options.log?.(String(b)))
- child.stderr?.on('data', b => this.options.log?.(String(b)))
- child.once('exit', code => {
- child.desktopExited = true
- if (this.child !== child) return
- this.child = null
- if (this.stopping || this.state.phase === 'starting') return
- this.options.log?.(`服务进程退出:${code}`)
- this.scheduleRestart(this.generation)
- })
- }
- const deadline = Date.now() + (this.options.timeoutMs ?? 30_000)
- let lastError
- while (Date.now() < deadline && !this.stopping && generation === this.generation) {
- record = await this.readRecord()
- if (record?.url) {
- try {
- const service = await this.verify(record)
- if (this.child && service.pid !== this.child.pid) throw new Error('服务启动期间归属发生变化')
- if (generation !== this.generation || this.stopping) return
- this.publish({ phase: 'ready', message: '本地服务已就绪', ...service, external: !this.child })
- return this.state
- } catch (e) { lastError = e }
- }
- if (!this.child && !record) throw new Error('服务进程未能启动,请查看日志后重试。')
- await sleep(100)
- }
- if (!this.stopping) throw new Error(lastError?.message || '服务启动超时,请查看日志后重试。')
- } catch (error) {
- if (generation !== this.generation || this.stopping) return
- await this.stopChild()
- this.publish({ phase: 'error', message: error.message })
- throw error
- }
- }
- scheduleRestart(generation) {
- const now = Date.now()
- this.restarts = this.restarts.filter(time => now - time < 5 * 60_000)
- if (this.restarts.length >= 3) { this.publish({ phase: 'error', message: '服务多次退出,已停止自动重启。请查看日志后重试。' }); return }
- this.restarts.push(now)
- this.publish({ phase: 'restarting', message: '服务已断开,正在恢复…' })
- this.restartTimer = setTimeout(() => {
- if (this.stopping || generation !== this.generation) return
- void this.start().catch(() => {})
- }, 500 * this.restarts.length)
- }
- async check() {
- if (this.state.phase !== 'ready') return
- try {
- if (this.state.legacy) {
- if (!isAlive(this.state.pid)) throw new Error('已有服务已停止')
- await localJSON(new URL('/api/status', this.state.url))
- } else {
- const record = await this.readRecord()
- if (!record) throw new Error('服务已停止')
- await this.verify(record)
- }
- } catch {
- this.publish({ phase: 'error', message: '本地服务连接已断开,请重试。' })
- }
- }
- async stopChild() {
- const child = this.child
- if (!child || child.desktopExited) return
- const exited = new Promise(resolve => child.once('exit', resolve))
- const record = await this.readRecord()
- if (record?.pid === child.pid && record.desktopOwned) {
- try { await localJSON(new URL('/desktop/service', record.url), { 'x-yaoyao-desktop-token': record.token }, 'DELETE') }
- catch { /* Owned process handle remains authoritative for cleanup. */ }
- } else child.kill()
- let timer
- await Promise.race([exited, new Promise(resolve => { timer = setTimeout(() => { child.kill(); resolve() }, 5000) })])
- clearTimeout(timer)
- if (!child.desktopExited) {
- await Promise.race([exited, sleep(2000)])
- if (!child.desktopExited && child.pid) {
- try { process.kill(child.pid, 'SIGKILL') } catch (error) { if (error.code !== 'ESRCH') throw error }
- await Promise.race([exited, sleep(2000)])
- }
- if (!child.desktopExited) throw new Error('旧服务进程尚未确认退出,暂不启动替代进程。')
- }
- if (this.child === child) this.child = null
- }
- async stop() {
- this.stopping = true
- clearTimeout(this.restartTimer)
- ++this.generation
- await this.starting?.catch(() => {})
- await this.stopChild()
- this.publish({ phase: 'stopped', message: '本地服务已停止' })
- }
- }
|