service-update.mjs 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372
  1. import { execFile, execFileSync } from 'node:child_process'
  2. import {promisify} from 'node:util'
  3. import { randomUUID, createHash } from 'node:crypto'
  4. import { chmodSync, cpSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
  5. import { homedir } from 'node:os'
  6. import { dirname, join, relative, resolve } from 'node:path'
  7. import { request as httpRequest } from 'node:http'
  8. import { request as httpsRequest } from 'node:https'
  9. import { DatabaseSync } from 'node:sqlite'
  10. import { readJSON, readRuntime, syncDecision, verifyRuntimePackage } from './runtime-release.mjs'
  11. import { backupData, databaseSchema, legacyDataIdle, restoreData } from './service-data.mjs'
  12. import { normalizeReleaseSource } from './release-source.mjs'
  13. import { deferDesktopActivation } from './desktop-activation.mjs'
  14. const sleep = ms => new Promise(done => setTimeout(done, ms))
  15. export const alive = pid => { try { process.kill(pid, 0); return true } catch (error) { return error.code !== 'ESRCH' } }
  16. export function writeJSON(path, value) {
  17. mkdirSync(dirname(path), { recursive: true, mode: 0o700 })
  18. const temporary = `${path}.${randomUUID()}`
  19. writeFileSync(temporary, JSON.stringify(value, null, 2) + '\n', { mode: 0o600 }); renameSync(temporary, path)
  20. }
  21. export function updateMutex(home) {
  22. mkdirSync(join(home, 'updates'), { recursive: true, mode: 0o700 })
  23. const db = new DatabaseSync(join(home, 'updates', 'mutex.sqlite3'))
  24. chmodSync(join(home, 'updates', 'mutex.sqlite3'), 0o600)
  25. try { db.exec('PRAGMA busy_timeout=0; BEGIN EXCLUSIVE') }
  26. catch { db.close(); throw new Error('已有系统更新正在执行,请稍后重试') }
  27. return () => db.close()
  28. }
  29. export function reserveUpdate(home, jobID) {
  30. const path = join(home, 'updates', 'active.lock')
  31. if (existsSync(path)) {
  32. const lock = readJSON(path)
  33. if (Number.isSafeInteger(lock.pid) && alive(lock.pid)) throw new Error('已有系统更新正在执行,请稍后重试')
  34. // Called only while the common SQLite update mutex is held.
  35. rmSync(path)
  36. }
  37. writeFileSync(path, JSON.stringify({ jobID, pid: process.pid }), { flag: 'wx', mode: 0o600 })
  38. return () => { if (existsSync(path) && readJSON(path).jobID === jobID) rmSync(path) }
  39. }
  40. export function localRequest(url, token, method = 'GET') {
  41. const target = new URL(url)
  42. if (!['http:', 'https:'].includes(target.protocol) || !['127.0.0.1', '[::1]', 'localhost'].includes(target.hostname) || target.username || target.password)
  43. throw new Error('升级只支持已核对的本机服务')
  44. return new Promise((done, reject) => {
  45. const req = (target.protocol === 'https:' ? httpsRequest : httpRequest)(target, {
  46. method, agent: false, headers: token ? { 'x-yaoyao-desktop-token': token } : {},
  47. }, res => {
  48. let data = ''
  49. res.on('data', bytes => { data += bytes; if (data.length > 128 * 1024) req.destroy(new Error('服务响应过大')) })
  50. res.on('end', () => { try { done({ status: res.statusCode, body: JSON.parse(data) }) } catch { reject(new Error('本地服务响应无效')) } })
  51. })
  52. req.on('error', reject); req.setTimeout(3000, () => req.destroy(new Error('本地服务请求超时'))); req.end()
  53. })
  54. }
  55. function command(bin, args, input) {
  56. try { return execFileSync(bin, args, { encoding: 'utf8', input, timeout: 30000, stdio: ['pipe', 'pipe', 'pipe'] }) }
  57. catch { throw new Error(`${bin.split('/').at(-1)} ${args[0]} 未完成`) }
  58. }
  59. export function currentRelease(releaseRoot) {
  60. const path = join(releaseRoot, 'current')
  61. if (!existsSync(path)) return undefined
  62. if (!lstatSync(path).isSymbolicLink()) throw new Error('发布目录 current 必须是符号链接')
  63. return realpathSync(path)
  64. }
  65. export function switchRelease(releaseRoot, target) {
  66. mkdirSync(releaseRoot, { recursive: true, mode: 0o700 })
  67. releaseRoot = realpathSync(releaseRoot)
  68. if (target) target = realpathSync(target)
  69. const path = join(releaseRoot, 'current')
  70. if (existsSync(path) && !lstatSync(path).isSymbolicLink()) throw new Error('拒绝替换非符号链接发布目录')
  71. if (!target) { rmSync(path, { force: true }); return }
  72. const temp = join(releaseRoot, `.current-${randomUUID()}`)
  73. symlinkSync(relative(releaseRoot, target), temp); renameSync(temp, path)
  74. }
  75. export class LaunchAgentService {
  76. constructor({ home, port = 15300, releaseRoot, label = 'com.samien.hermes-yaoyao', plistPath, environment = {} }) {
  77. this.home = realpathSync(home); this.port = port; this.releaseRoot = resolve(releaseRoot)
  78. this.label = label; this.domain = `gui/${process.getuid()}`
  79. this.plistPath = plistPath ?? join(homedir(), 'Library', 'LaunchAgents', `${label}.plist`)
  80. this.environment = environment
  81. }
  82. snapshot() {
  83. if (!existsSync(this.plistPath)) {
  84. // Never install a second service over an unregistered listener or owner.
  85. if (this.listeners().size || this.record()) throw new Error('已有服务未注册为此 App 的本机后台服务,暂不接管')
  86. return undefined
  87. }
  88. const plist = JSON.parse(command('/usr/bin/plutil', ['-convert', 'json', '-o', '-', this.plistPath]))
  89. const env = plist.EnvironmentVariables ?? {}, root = realpathSync(plist.WorkingDirectory)
  90. this.tls = Boolean(env.HERMES_YAOYAO_TLS_CERT)
  91. if (plist.Label !== this.label || realpathSync(env.HERMES_YAOYAO_HOME ?? join(homedir(), '.hermes-yaoyao')) !== this.home || Number(env.HERMES_YAOYAO_PORT ?? 15300) !== this.port)
  92. throw new Error('已有服务的数据目录或端口与 App 不一致,暂不接管')
  93. const args = plist.ProgramArguments
  94. if (!Array.isArray(args) || args.length !== 2 || !['server.mjs', 'dist-server/server/index.js'].some(entry => realpathSync(args[1]) === join(root, entry)))
  95. throw new Error('已有服务的启动入口无法核对')
  96. readRuntime(root)
  97. const pid = this.pid(), listeners = this.listeners()
  98. if (listeners.size && (listeners.size !== 1 || !listeners.has(pid))) throw new Error('端口由另一个进程占用,暂不接管')
  99. if (pid) {
  100. const holders = this.holders(join(this.home, 'workspace.sqlite3'))
  101. if (!holders.has(pid)) throw new Error('无法确认现有服务的数据归属')
  102. }
  103. return { plist, root, wasRunning: Boolean(pid) }
  104. }
  105. pid() {
  106. try { return Number(execFileSync('launchctl', ['print', `${this.domain}/${this.label}`], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).match(/^\s*pid = (\d+)$/m)?.[1]) || undefined }
  107. catch { return undefined }
  108. }
  109. holders(path) {
  110. try { return new Set(execFileSync('/usr/sbin/lsof', ['-t', path], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim().split(/\s+/).map(Number)) }
  111. catch { return new Set() }
  112. }
  113. listeners(established = false) {
  114. const pid = established ? this.pid() : undefined
  115. if (established && !pid) return new Set()
  116. try { return new Set(execFileSync('/usr/sbin/lsof', [...(pid ? ['-a', '-p', String(pid)] : []), '-nP', `-iTCP:${this.port}`, '-sTCP:' + (established ? 'ESTABLISHED' : 'LISTEN'), '-t'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim().split(/\s+/).map(Number)) }
  117. catch { return new Set() }
  118. }
  119. record() {
  120. try {
  121. const value = readJSON(join(this.home, 'service-instance.json'))
  122. return value.protocol === 1 && value.dataKey === createHash('sha256').update(this.home).digest('hex') && Number.isSafeInteger(value.pid) && alive(value.pid) ? value : undefined
  123. } catch { return undefined }
  124. }
  125. url(record = this.record()) {
  126. if (!record?.url) return `${this.tls ? 'https' : 'http'}://127.0.0.1:${this.port}`
  127. const url = new URL(record.url)
  128. if (Number(url.port) !== this.port) throw new Error('本机服务端口发生变化')
  129. if (url.hostname === '0.0.0.0') url.hostname = '127.0.0.1'
  130. return url.origin
  131. }
  132. async quiesce(onProgress, timeout = 60000) {
  133. if (!this.pid()) return
  134. const deadline = Date.now() + timeout
  135. do {
  136. const record = this.record()
  137. if (record?.maintenanceProtocol === 1) {
  138. if (record.pid !== this.pid()) throw new Error('服务进程归属发生变化')
  139. const response = await localRequest(`${this.url(record)}/desktop/service/quiesce`, record.token, 'POST')
  140. if (response.status === 200 && response.body.quiesced === true) return
  141. if (response.status !== 409) throw new Error('服务未接受更新准备请求')
  142. } else if (legacyDataIdle(this.home) && !this.listeners(true).size) {
  143. return
  144. }
  145. onProgress('正在等待聊天和后台任务结束后同步 Web…')
  146. if (Date.now() >= deadline) break
  147. await sleep(1000)
  148. } while (Date.now() < deadline)
  149. throw Object.assign(new Error('当前仍有任务或旧版客户端连接,已延后同步;任务结束并关闭其他客户端后可重试'), { code: 'service_busy' })
  150. }
  151. async resume() {
  152. const record = this.record()
  153. if (record?.maintenanceProtocol === 1) await localRequest(`${this.url(record)}/desktop/service/quiesce`, record.token, 'DELETE').catch(() => {})
  154. }
  155. async preserveComputerData(finalRoot){
  156. const helper=join(finalRoot,'vm-data-migrate.mjs')
  157. if(!existsSync(helper))return // Older release packages have no migration hook.
  158. try{await promisify(execFile)(join(finalRoot,'node'),[helper,this.home],{timeout:600000,maxBuffer:32768})}
  159. catch{throw new Error('虚拟机用户资料迁移未完成,已取消服务切换并保留原容器')}
  160. }
  161. async stop() {
  162. const pid = this.pid()
  163. try { execFileSync('launchctl', ['print', `${this.domain}/${this.label}`], { stdio: 'ignore' }); command('launchctl', ['bootout', `${this.domain}/${this.label}`]) }
  164. catch (error) { if (this.pid()) throw error }
  165. const deadline = Date.now() + 15000
  166. while (pid && alive(pid) && Date.now() < deadline) await sleep(100)
  167. if ((pid && alive(pid)) || this.listeners().size || this.holders(join(this.home, 'workspace.sqlite3')).size)
  168. throw new Error('后台服务尚未完全停止,操作未完成')
  169. }
  170. writePlist(plist) {
  171. plist.EnvironmentVariables ??= {}
  172. plist.EnvironmentVariables.HERMES_YAOYAO_RELEASE_SOURCE = normalizeReleaseSource(plist.EnvironmentVariables.HERMES_YAOYAO_RELEASE_SOURCE)
  173. mkdirSync(dirname(this.plistPath), { recursive: true })
  174. const xml = command('/usr/bin/plutil', ['-convert', 'xml1', '-o', '-', '--', '-'], JSON.stringify(plist))
  175. const temporary = `${this.plistPath}.${randomUUID()}`
  176. writeFileSync(temporary, xml, { mode: 0o600 }); renameSync(temporary, this.plistPath)
  177. }
  178. prepareBoot(deferHermes) {
  179. // A live service is only being reused. Its authorization and background
  180. // work must survive inspection; reset only a service we are about to boot.
  181. if (deferHermes && !this.pid()) {
  182. const plist = JSON.parse(command('/usr/bin/plutil', ['-convert', 'json', '-o', '-', this.plistPath]))
  183. plist.EnvironmentVariables ??= {}
  184. plist.EnvironmentVariables.HERMES_YAOYAO_DEFER_HERMES = '1'
  185. this.writePlist(plist)
  186. deferDesktopActivation(this.home)
  187. return true
  188. }
  189. return false
  190. }
  191. async boot({ deferHermes = this.environment.HERMES_YAOYAO_DEFER_HERMES === '1' } = {}) {
  192. const prepared = this.prepareBoot(deferHermes)
  193. let loaded = false
  194. try { execFileSync('launchctl', ['print', `${this.domain}/${this.label}`], { stdio: 'ignore' }); loaded = true } catch { /* Not registered yet. */ }
  195. // launchd caches a loaded job's environment even when it has no process.
  196. // Reload that stopped job so its next process receives the deferred mode.
  197. if (loaded && prepared && !this.pid()) {
  198. command('launchctl', ['bootout', `${this.domain}/${this.label}`])
  199. loaded = false
  200. }
  201. if (loaded) { command('launchctl', ['kickstart', `${this.domain}/${this.label}`]); return }
  202. const deadline = Date.now() + 20000
  203. for (;;) {
  204. try { command('launchctl', ['bootstrap', this.domain, this.plistPath]); return }
  205. catch (error) { if (Date.now() >= deadline) throw error; await sleep(500) }
  206. }
  207. }
  208. async start(root, previous) {
  209. const stable = join(this.releaseRoot, 'current')
  210. const node = existsSync(join(root, 'node')) ? join(root, 'node') : previous?.plist.ProgramArguments[0] ?? process.execPath
  211. const plist = previous ? structuredClone(previous.plist) : {
  212. Label: this.label, RunAtLoad: true, KeepAlive: true, ThrottleInterval: 10, ProcessType: 'Interactive', Umask: 63,
  213. StandardOutPath: join(this.home, 'service.log'), StandardErrorPath: join(this.home, 'service.log'),
  214. EnvironmentVariables: { PATH: `${dirname(node)}:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin`, HERMES_YAOYAO_HOST: '127.0.0.1', ...this.environment },
  215. }
  216. plist.ProgramArguments = [node, join(stable, existsSync(join(root, 'server.mjs')) ? 'server.mjs' : 'dist-server/server/index.js')]
  217. plist.WorkingDirectory = stable
  218. Object.assign(plist.EnvironmentVariables, { NODE_ENV: 'production', HERMES_YAOYAO_HOME: this.home, HERMES_YAOYAO_PORT: String(this.port),
  219. HERMES_YAOYAO_SERVICE_ROOT: stable, HERMES_YAOYAO_RELEASE_ROOT: this.releaseRoot, HERMES_YAOYAO_DESKTOP: '0',
  220. HERMES_YAOYAO_STATIC_DIR: join(stable, existsSync(join(root, 'ui')) ? 'ui' : 'dist') })
  221. if (this.environment.HERMES_YAOYAO_DEFER_HERMES === '1') plist.EnvironmentVariables.HERMES_YAOYAO_DEFER_HERMES = '1'
  222. delete plist.EnvironmentVariables.ELECTRON_RUN_AS_NODE
  223. this.writePlist(plist); await this.boot()
  224. }
  225. async restore(previous) {
  226. if (!previous) { rmSync(this.plistPath, { force: true }); return }
  227. this.writePlist(previous.plist)
  228. if (previous.wasRunning && !this.pid()) await this.boot({ deferHermes: false })
  229. }
  230. async verify(expected, timeout = 45000) {
  231. const deadline = Date.now() + timeout
  232. do {
  233. try {
  234. const record = this.record(), url = this.url(record)
  235. if (record?.build && record.pid === this.pid()) {
  236. const identity = await localRequest(`${url}/desktop/service`, record.token)
  237. const actual = identity.body
  238. if (identity.status !== 200 || actual.instanceId !== record.instanceId || actual.pid !== record.pid || actual.dataKey !== record.dataKey) throw new Error('实例不匹配')
  239. if (expected && (actual.version !== expected.version || actual.build.commit !== expected.commit || (expected.artifactDigest && actual.build.artifactDigest !== expected.artifactDigest))) throw new Error('运行构建不匹配')
  240. } else if (expected?.hasBuildIdentity || expected?.artifactDigest || !this.pid()) throw new Error('新构建尚未就绪')
  241. const health = await localRequest(`${url}/healthz`)
  242. const status = await localRequest(`${url}/api/status`)
  243. if (health.status === 200 && health.body.ok && status.status === 200 && status.body.server_kind === 'yaoyao-web') return
  244. } catch { /* Startup and launchd recovery can take a moment. */ }
  245. await sleep(300)
  246. } while (Date.now() < deadline)
  247. throw new Error('新服务的构建、健康或接口核验失败')
  248. }
  249. }
  250. export async function recoverTransition({ home, releaseRoot, driver, onProgress = () => {} }) {
  251. const path = join(home, 'updates', 'transition.json')
  252. if (!existsSync(path)) return
  253. const journal = readJSON(path)
  254. if (journal.home !== realpathSync(home) || journal.releaseRoot !== resolve(releaseRoot)) throw new Error('未完成更新的归属不一致')
  255. onProgress('正在恢复上一次未完成的更新…')
  256. if (journal.phase !== 'restored') {
  257. await driver.stop()
  258. if (journal.snapshot) restoreData(home, journal.snapshot)
  259. switchRelease(releaseRoot, journal.previousCurrent)
  260. const success = join(home, 'updates', 'last-success.json')
  261. if (journal.previousSuccess) writeJSON(success, journal.previousSuccess)
  262. else rmSync(success, { force: true })
  263. journal.phase = 'restored'; writeJSON(path, journal)
  264. }
  265. await driver.restore(journal.previous)
  266. if (journal.previous?.wasRunning) await driver.verify(undefined)
  267. rmSync(path)
  268. await driver.resume()
  269. }
  270. export async function transitionService({ home, releaseRoot, driver, finalRoot, onProgress = () => {}, rollback = false, waitForIdleMs }) {
  271. const previous = driver.snapshot(), previousCurrent = currentRelease(releaseRoot)
  272. const path = join(home, 'updates', 'transition.json'), id = randomUUID()
  273. const success = join(home, 'updates', 'last-success.json')
  274. const journal = { id, home: realpathSync(home), releaseRoot: resolve(releaseRoot), previous, previousCurrent, finalRoot,
  275. previousSuccess: existsSync(success) ? readJSON(success) : undefined }
  276. const expected = readRuntime(finalRoot)
  277. try {
  278. if (previous?.wasRunning) await driver.verify(undefined, 5000)
  279. await driver.quiesce(onProgress, waitForIdleMs)
  280. onProgress('正在保留虚拟机用户资料…')
  281. await driver.preserveComputerData?.(finalRoot)
  282. onProgress('正在备份数据并切换 Web 服务…')
  283. // Persist before stopping; recovery never mistakes a partial backup for a complete snapshot.
  284. writeJSON(path, journal)
  285. await driver.stop()
  286. journal.snapshot = join(home, 'updates', 'backups', id)
  287. let beforeSchema
  288. try { beforeSchema = backupData(home, journal.snapshot) }
  289. catch (error) { rmSync(journal.snapshot, { recursive: true, force: true }); throw error }
  290. writeJSON(path, journal)
  291. switchRelease(releaseRoot, finalRoot)
  292. await driver.start(finalRoot, previous)
  293. onProgress('正在核对 Web 构建及服务状态…')
  294. await driver.verify(expected)
  295. const afterSchema = databaseSchema(home)
  296. // The new process rejects application traffic until the durable journal is removed.
  297. const result = { previousCurrentTarget: previousCurrent, previousServiceRoot: previous?.root, previous,
  298. finalRoot, commit: expected.commit, target: expected.release, snapshot: journal.snapshot, beforeSchema, afterSchema }
  299. if (!rollback) writeJSON(join(home, 'updates', 'last-success.json'), result)
  300. rmSync(path)
  301. await driver.resume()
  302. return result
  303. } catch (error) {
  304. if (existsSync(path)) {
  305. try { await recoverTransition({ home, releaseRoot, driver, onProgress }) }
  306. catch (recovery) { throw new Error(`${error.message};恢复未完成:${recovery.message}`) }
  307. } else await driver.resume()
  308. throw error
  309. }
  310. }
  311. export async function synchronizeDesktop({ home, runtimeRoot, releaseRoot, driver, onProgress = () => {}, force = false }) {
  312. if (typeof force !== 'boolean') throw new Error('强制覆盖参数无效')
  313. mkdirSync(home, { recursive: true, mode: 0o700 })
  314. const unlock = updateMutex(home), id = randomUUID()
  315. let release
  316. try {
  317. release = reserveUpdate(home, id)
  318. await recoverTransition({ home, releaseRoot, driver, onProgress })
  319. onProgress('正在核对 App 配套 Web 服务…')
  320. const target = verifyRuntimePackage(runtimeRoot)
  321. const marker = join(home, 'updates', 'desktop-sync.json')
  322. const previous = driver.snapshot(), current = previous ? readRuntime(previous.root) : undefined
  323. if (force && current && current.version !== target.version)
  324. throw Object.assign(new Error('强制覆盖仅支持相同版本;跨版本更新或回退请使用对应入口'), { code: 'service_force_version_mismatch' })
  325. if (!force && existsSync(marker) && readJSON(marker).artifactDigest === target.artifactDigest && previous) {
  326. if (!previous.wasRunning) { driver.writePlist(previous.plist); await driver.boot() }
  327. await driver.verify(undefined)
  328. return { action: 'already-synced', current }
  329. }
  330. const comparison = syncDecision(target, current)
  331. const decision = force && current && comparison !== 'same' ? 'overwrite' : comparison
  332. if (decision === 'unknown') throw Object.assign(new Error('App 与 Web 版本相同,但构建先后关系无法确定。可使用当前 App 覆盖 Web;聊天、设置和文件会保留,并先备份。'), { code: 'service_build_unknown' })
  333. if (decision === 'same' || decision === 'newer') {
  334. if (!previous.wasRunning) { driver.writePlist(previous.plist); await driver.boot() }
  335. await driver.verify(undefined)
  336. writeJSON(marker, { artifactDigest: target.artifactDigest, action: decision, completedAt: new Date().toISOString() })
  337. return { action: decision, current }
  338. }
  339. const finalRoot = join(releaseRoot, 'releases', `${target.version}-${target.artifactDigest.slice(0, 16)}`)
  340. if (!existsSync(finalRoot)) {
  341. const staging = join(releaseRoot, 'releases', `.staging-${id}`)
  342. mkdirSync(dirname(staging), { recursive: true, mode: 0o700 })
  343. try { cpSync(runtimeRoot, staging, { recursive: true }); verifyRuntimePackage(staging); chmodSync(join(staging, 'node'), 0o755); renameSync(staging, finalRoot) }
  344. finally { rmSync(staging, { recursive: true, force: true }) }
  345. }
  346. verifyRuntimePackage(finalRoot)
  347. await transitionService({ home, releaseRoot, driver, finalRoot, onProgress, waitForIdleMs: 0 })
  348. writeJSON(marker, { artifactDigest: target.artifactDigest, action: decision, completedAt: new Date().toISOString() })
  349. return { action: decision, current: target }
  350. } finally { release?.(); unlock() }
  351. }
  352. /** Explicit native Stop command. It never changes the release or user data,
  353. * and unloads the verified LaunchAgent so KeepAlive cannot restart it. */
  354. export async function stopDesktopService({home,driver,onProgress=()=>{}}) {
  355. const unlock=updateMutex(home)
  356. let release
  357. try {
  358. release=reserveUpdate(home,randomUUID())
  359. if(existsSync(join(home,'updates','transition.json')))throw new Error('系统更新尚未完成,请完成更新后再停止后台服务')
  360. const previous=driver.snapshot()
  361. if(!previous)return {action:'stopped'}
  362. if(previous.wasRunning)await driver.verify(undefined,5000)
  363. onProgress('正在停止后台任务与虚拟机…')
  364. await driver.stop()
  365. onProgress('后台服务已停止')
  366. return {action:'stopped'}
  367. } finally {release?.();unlock()}
  368. }