瀏覽代碼

实现 GitHub 稳定版本升级与桌面安装包下载校验

LSamien 1 月之前
父節點
當前提交
c25aced18b

+ 1 - 0
Dockerfile

@@ -40,6 +40,7 @@ COPY --chown=node:node package.json package-lock.json release.json ./
 COPY --chown=node:node --from=builder /app/node_modules ./node_modules
 COPY --chown=node:node --from=builder /app/dist ./dist
 COPY --chown=node:node --from=builder /app/dist-server ./dist-server
+COPY --chown=node:node --from=builder /app/bin ./bin
 COPY --chown=node:node --from=builder /app/build-info.json /app/runner-bundle.tar.gz ./
 
 USER node

+ 2 - 0
README.md

@@ -61,6 +61,8 @@ iOS 统一连接 Web 地址,通过设置中的手机登录二维码或账号
 
 ## 数据与升级
 
+Web 默认从 [GitHub Releases](https://github.com/Lsamien/hermes-yaoyao/releases) 检查稳定版本;桌面菜单“检查 App 更新…”可下载并校验 DMG 后手动安装。旧官方发布源会自动归一化为 GitHub,自定义仓库保留。详见 [GitHub 版本升级](docs/github-updates.md)。
+
 发布版本由 `release.json` 记录。macOS 本机服务支持在系统设置中升级 Web 或回滚到上一个版本;其他源码部署更新代码、重新构建并重启服务,Docker 部署通过重新构建镜像并重建容器更新。升级前备份 Web 数据目录,客户端版本要求见对应发布说明。
 
 ## 开发验证

+ 3 - 1
bin/hermes-yaoyao.mjs

@@ -7,6 +7,7 @@ import { homedir } from 'node:os'
 import { dirname, join, resolve } from 'node:path'
 import { setTimeout as delay } from 'node:timers/promises'
 import { fileURLToPath } from 'node:url'
+import { normalizeReleaseSource } from './lib/release-source.mjs'
 
 const label = 'com.samien.hermes-yaoyao'
 const sourceProjectRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
@@ -58,7 +59,8 @@ function environment(env = process.env) {
     'HERMES_YAOYAO_FCM_PACKAGE_NAME',
     'HERMES_YAOYAO_SERVICE_ROOT',
   ]
-  return Object.fromEntries(allowed.flatMap(key => env[key] ? [[key, env[key]]] : []))
+  return { ...Object.fromEntries(allowed.flatMap(key => env[key] ? [[key, env[key]]] : [])),
+    HERMES_YAOYAO_RELEASE_SOURCE: normalizeReleaseSource(env.HERMES_YAOYAO_RELEASE_SOURCE) }
 }
 
 export function launchAgentPlist(options = {}) {

+ 2 - 0
bin/lib/release-source.d.mts

@@ -0,0 +1,2 @@
+export const DEFAULT_RELEASE_SOURCE: string
+export function normalizeReleaseSource(value?: string): string

+ 22 - 0
bin/lib/release-source.mjs

@@ -0,0 +1,22 @@
+export const DEFAULT_RELEASE_SOURCE = 'https://github.com/Lsamien/hermes-yaoyao.git'
+
+/** Only the former official repository is migrated; private forks stay private. */
+export function normalizeReleaseSource(value) {
+  const source = value?.trim()
+  if (!source) return DEFAULT_RELEASE_SOURCE
+  if (/^git@git\.samien\.cn:samien\/hermes-yaoyao(?:\.git)?\/?$/.test(source)) return DEFAULT_RELEASE_SOURCE
+  try {
+    const url = new URL(source)
+    const path = url.pathname.replace(/\/$/, '').replace(/\.git$/, '')
+    if (!url.username && !url.password && !url.search && !url.hash
+      && ['https:', 'http:', 'ssh:'].includes(url.protocol)
+      && ['git.samien.cn', '192.168.153.8:3000'].includes(url.host)
+      && path === '/samien/hermes-yaoyao') return DEFAULT_RELEASE_SOURCE
+    if (url.protocol === 'https:' && url.hostname === 'github.com' && !url.port
+      && !url.username && !url.password && !url.search && !url.hash && /^\/[\w.-]+\/[\w.-]+$/.test(path)) {
+      const normalized = `https://github.com${path}.git`
+      return normalized.toLowerCase() === DEFAULT_RELEASE_SOURCE.toLowerCase() ? DEFAULT_RELEASE_SOURCE : normalized
+    }
+  } catch { /* Custom SSH sources retain their existing Git transport. */ }
+  return source
+}

+ 3 - 0
bin/lib/service-update.mjs

@@ -8,6 +8,7 @@ import { request as httpsRequest } from 'node:https'
 import { DatabaseSync } from 'node:sqlite'
 import { readJSON, readRuntime, syncDecision, verifyRuntimePackage } from './runtime-release.mjs'
 import { backupData, databaseSchema, legacyDataIdle, restoreData } from './service-data.mjs'
+import { normalizeReleaseSource } from './release-source.mjs'
 
 const sleep = ms => new Promise(done => setTimeout(done, ms))
 export const alive = pid => { try { process.kill(pid, 0); return true } catch (error) { return error.code !== 'ESRCH' } }
@@ -160,6 +161,8 @@ export class LaunchAgentService {
       throw new Error('后台服务尚未完全停止,操作未完成')
   }
   writePlist(plist) {
+    plist.EnvironmentVariables ??= {}
+    plist.EnvironmentVariables.HERMES_YAOYAO_RELEASE_SOURCE = normalizeReleaseSource(plist.EnvironmentVariables.HERMES_YAOYAO_RELEASE_SOURCE)
     mkdirSync(dirname(this.plistPath), { recursive: true })
     const xml = command('/usr/bin/plutil', ['-convert', 'xml1', '-o', '-', '--', '-'], JSON.stringify(plist))
     const temporary = `${this.plistPath}.${randomUUID()}`

+ 1 - 0
compose.desktops.yaml

@@ -31,6 +31,7 @@ services:
       HERMES_YAOYAO_LOCAL_VM_HOST: "runner"
       HERMES_YAOYAO_COMPOSE_DESKTOPS: "[{\"id\":\"160fe7cd-e530-4ac5-a97f-26c19a362b11\",\"name\":\"共享桌面 1\",\"socketPath\":\"/run/yaoyao-desktops/desktop-1/desktop.sock\"},{\"id\":\"eba5caaf-744e-4f27-bcdd-1f1f35d05a42\",\"name\":\"共享桌面 2\",\"socketPath\":\"/run/yaoyao-desktops/desktop-2/desktop.sock\"}]"
       HERMES_YAOYAO_CHAT_CACHE_MODE: "${HERMES_YAOYAO_CHAT_CACHE_MODE:-prefer-local}"
+      HERMES_YAOYAO_RELEASE_SOURCE: "${HERMES_YAOYAO_RELEASE_SOURCE:-https://github.com/Lsamien/hermes-yaoyao.git}"
       HERMES_YAOYAO_HOME: /var/lib/hermes-yaoyao
       HERMES_YAOYAO_APNS_KEY_FILE: "${HERMES_YAOYAO_APNS_KEY_FILE:-}"
       HERMES_YAOYAO_APNS_KEY_ID: "${HERMES_YAOYAO_APNS_KEY_ID:-}"

+ 1 - 0
compose.yaml

@@ -29,6 +29,7 @@ services:
       HERMES_YAOYAO_SUPERVISE_DASHBOARD: "0"
       HERMES_YAOYAO_LOCAL_VM_HOST: "runner"
       HERMES_YAOYAO_CHAT_CACHE_MODE: "${HERMES_YAOYAO_CHAT_CACHE_MODE:-prefer-local}"
+      HERMES_YAOYAO_RELEASE_SOURCE: "${HERMES_YAOYAO_RELEASE_SOURCE:-https://github.com/Lsamien/hermes-yaoyao.git}"
       HERMES_YAOYAO_HOME: /var/lib/hermes-yaoyao
       HERMES_YAOYAO_APNS_KEY_FILE: "${HERMES_YAOYAO_APNS_KEY_FILE:-}"
       HERMES_YAOYAO_APNS_KEY_ID: "${HERMES_YAOYAO_APNS_KEY_ID:-}"

+ 41 - 2
desktop/main.mjs

@@ -1,4 +1,4 @@
-import { app, BrowserWindow, ipcMain, Menu, Tray, nativeImage, shell, utilityProcess, powerMonitor, systemPreferences, dialog, safeStorage } from 'electron'
+import { app, BrowserWindow, ipcMain, Menu, Tray, nativeImage, shell, utilityProcess, powerMonitor, systemPreferences, dialog, safeStorage, net } from 'electron'
 import { appendFileSync, mkdirSync, readFileSync, realpathSync } from 'node:fs'
 import { join, dirname } from 'node:path'
 import { homedir } from 'node:os'
@@ -8,6 +8,7 @@ import { DesktopRunnerManager } from './runner-manager.mjs'
 import { DesktopPreferences } from './preferences.mjs'
 import { DesktopCredentials } from './credentials.mjs'
 import { synchronizeLocalService, stopLocalService } from './service-sync.mjs'
+import { DesktopUpdateManager } from './update-manager.mjs'
 
 const desktopRoot = dirname(fileURLToPath(import.meta.url))
 const fixtureHome = process.env.HERMES_YAOYAO_DESKTOP_TEST_HOME
@@ -17,6 +18,8 @@ app.setName('夭夭')
 if (!app.requestSingleInstanceLock()) { app.quit() }
 else {
   let window, tray, manager, runnerManager, quitting = false, closing = false, timer
+  let updateWindow, updater
+  const updateURL = pathToFileURL(join(desktopRoot, 'update.html')).href
   const bootURL = pathToFileURL(join(desktopRoot, 'boot.html')).href
   const root = app.isPackaged ? join(process.resourcesPath, 'runtime') : join(app.getAppPath(), '.desktop-build')
   const logRoot = join(app.getPath('logs'), fixtureHome ? 'verification' : 'service')
@@ -43,6 +46,36 @@ else {
     if (!trustedBoot(event)) throw new Error('不允许此页面读取日志')
     shell.showItemInFolder(logFile)
   })
+  const trustedUpdate = event => event.sender === updateWindow?.webContents
+    && event.senderFrame === updateWindow.webContents.mainFrame && event.senderFrame.url === updateURL
+  for (const action of ['state', 'check', 'download', 'cancel', 'open', 'folder', 'release']) {
+    ipcMain.handle(`desktop-update:${action}`, async event => {
+      if (!trustedUpdate(event) || closing || quitting) throw new Error('不允许此页面操作 App 更新')
+      if (action === 'state') return updater.snapshot()
+      if (action === 'check') return updater.check()
+      if (action === 'download') return updater.download()
+      if (action === 'cancel') { updater.cancel(); return updater.snapshot() }
+      if (action === 'release') { safeExternal(updater.snapshot().releasePageUrl || 'https://github.com/Lsamien/hermes-yaoyao/releases'); return }
+      const path = await updater.verifiedFile()
+      if (closing || quitting || !trustedUpdate(event)) return
+      if (action === 'folder') shell.showItemInFolder(path)
+      else { const error = await shell.openPath(path); if (error) throw new Error(error) }
+    })
+  }
+  function showUpdates() {
+    if (closing || quitting) return
+    if (updateWindow && !updateWindow.isDestroyed()) { updateWindow.show(); updateWindow.focus(); return }
+    updateWindow = new BrowserWindow({ title: 'App 更新 · 夭夭', width: 640, height: 620, minWidth: 360, minHeight: 440, show: false,
+      webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true, webSecurity: true,
+        preload: join(desktopRoot, 'update-preload.cjs') } })
+    updateWindow.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
+    updateWindow.webContents.on('will-navigate', event => event.preventDefault())
+    updateWindow.webContents.on('will-redirect', event => event.preventDefault())
+    updateWindow.webContents.on('will-attach-webview', event => event.preventDefault())
+    updateWindow.on('closed', () => { updater.cancel(); updateWindow = undefined })
+    updateWindow.once('ready-to-show', () => updateWindow?.show())
+    void updateWindow.loadURL(updateURL)
+  }
 
   function show() {
     if (closing || quitting) return
@@ -116,6 +149,7 @@ else {
   function requestQuit(stopBackground = false) {
     if (closing || quitting) return
     closing = true; clearInterval(timer)
+    updater?.cancel(); updateWindow?.close()
     window?.hide()
     // Native Cmd+Q can enter before-quit from Cocoa's termination callback.
     // Both cleanup and the final quit must run after that callback unwinds.
@@ -152,6 +186,10 @@ else {
     startHidden = preferences.value.backgroundAtLogin && app.getLoginItemSettings().wasOpenedAtLogin
     const packageVersion = JSON.parse(readFileSync(join(root, 'release.json'), 'utf8')).webVersion
     const buildInfo=JSON.parse(readFileSync(join(root,'build-info.json'),'utf8'))
+    const releases = await import(pathToFileURL(join(root, 'github-release.mjs')).href)
+    updater = new DesktopUpdateManager({ version: packageVersion, cacheRoot: join(home, 'updates', 'desktop-downloads'),
+      source: releases.DEFAULT_RELEASE_SOURCE, inspect: releases.inspectGitHubRelease, compare: releases.compareReleaseVersions,
+      fetchImpl: (...args) => net.fetch(...args) })
     app.setAboutPanelOptions({applicationName:'夭夭',applicationVersion:packageVersion,version:`${String(buildInfo.commit).slice(0,12)}${buildInfo.dirty?' · 工作区快照':''}`})
     manager = new DesktopServiceManager({ home, port, version: packageVersion, log, onState: stateChanged,
       stopBackground: (app.isPackaged && !fixtureHome) || (fixtureHome && process.env.HERMES_YAOYAO_DESKTOP_TEST_SYNC === '1')
@@ -199,7 +237,8 @@ else {
           click:async item=>{try{app.setLoginItemSettings({openAtLogin:item.checked});item.checked=app.getLoginItemSettings().openAtLogin}catch(error){item.checked=app.getLoginItemSettings().openAtLogin;await dialog.showMessageBox(window,{type:'error',message:'无法修改登录启动设置',detail:error.message})}} },
         { id:'desktop-background', label:'登录启动时仅驻留菜单栏', type:'checkbox', checked:preferences.value.backgroundAtLogin,
           click:async item=>{try{await preferences.setBackgroundAtLogin(item.checked)}catch(error){item.checked=preferences.value.backgroundAtLogin;await dialog.showMessageBox(window,{type:'error',message:error.message})}} },
-        { id:'desktop-update-help', label:'App 更新与回退…', click:()=>dialog.showMessageBox(window,{type:'info',message:`夭夭 App ${packageVersion}`,detail:`App 构建:${String(buildInfo.commit).slice(0,12)}\n当前 Web:${manager.state.version||'尚未就绪'}${manager.state.build?.commit?' · '+manager.state.build.commit.slice(0,12):''}${manager.state.external?'(独立后台服务)':'(开发模式)'}\n\n安装或更新 App 后首次启动,会将较旧的本机 Web 同步到配套构建。已有更新的 Web 会保留;同一个 App 构建不会重复同步。\n\nWeb 可以继续自行升级。独立 Web 服务在退出 App 后继续运行;App 管理的执行节点会停止。\n\n降级通过 Web 的回滚入口单独处理。数据库结构不兼容时,需要独立的数据恢复流程。\n\n数据目录:${home}`,buttons:['知道了','打开数据目录']}).then(result=>{if(result.response===1)shell.showItemInFolder(home)}) },
+        { id:'desktop-update-check', label:'检查 App 更新…', click:showUpdates },
+        { id:'desktop-update-help', label:'App 更新与回退…', click:()=>dialog.showMessageBox(window,{type:'info',message:`夭夭 App ${packageVersion}`,detail:`App 构建:${String(buildInfo.commit).slice(0,12)}\n当前 Web:${manager.state.version||'尚未就绪'}${manager.state.build?.commit?' · '+manager.state.build.commit.slice(0,12):''}${manager.state.external?'(独立后台服务)':'(开发模式)'}\n\n通过“检查 App 更新”从 GitHub 下载并校验 DMG,手动拖入应用程序安装。开发签名包尚未公证,请遵循 macOS 的安装提示。\n\n安装后首次启动同步较旧的本机 Web,已有较新的 Web 保留。Web 可以独立升级,退出 App 保留独立后台服务。\n\n降级通过 Web 的回滚入口处理;数据库结构不兼容时不能直接降级。\n\n数据目录:${home}`,buttons:['知道了','打开数据目录']}).then(result=>{if(result.response===1)shell.showItemInFolder(home)}) },
         { type: 'separator' },
         { role: 'hide', label: '隐藏夭夭' }, { role: 'hideOthers', label: '隐藏其他' }, { role: 'unhide', label: '显示全部' },
         { type: 'separator' },

+ 135 - 0
desktop/update-manager.mjs

@@ -0,0 +1,135 @@
+import { createHash, randomUUID } from 'node:crypto'
+import { createReadStream } from 'node:fs'
+import { mkdir, open, rename, rm, lstat } from 'node:fs/promises'
+import { join } from 'node:path'
+import { execFile } from 'node:child_process'
+import { promisify } from 'node:util'
+
+const hosts = new Set(['github.com', 'release-assets.githubusercontent.com', 'objects.githubusercontent.com', 'github-releases.githubusercontent.com'])
+export async function githubDownload(url, fetchImpl, signal) {
+  for (let redirect = 0; redirect < 6; redirect++) {
+    const parsed = new URL(url)
+    if (parsed.protocol !== 'https:' || parsed.port || parsed.username || parsed.password || !hosts.has(parsed.hostname)) throw new Error('安装包下载地址不受信任')
+    const response = await fetchImpl(parsed.href, { redirect: 'manual', signal })
+    if ([301, 302, 303, 307, 308].includes(response.status)) {
+      const location = response.headers.get('location')
+      await response.body?.cancel()
+      if (!location) throw new Error('安装包下载重定向无效')
+      url = new URL(location, parsed).href; continue
+    }
+    if (!response.ok) throw new Error(`安装包下载返回 HTTP ${response.status}`)
+    return response
+  }
+  throw new Error('安装包下载重定向过多')
+}
+async function fileHash(path, signal) {
+  const stat = await lstat(path)
+  if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('安装包缓存不是普通文件')
+  const hash = createHash('sha256')
+  for await (const chunk of createReadStream(path, { signal })) hash.update(chunk)
+  return { size: stat.size, hash: hash.digest('hex') }
+}
+function assertDigest(digest, actual) {
+  if (digest && digest !== `sha256:${actual}`) throw new Error('GitHub 附件摘要与下载内容不一致')
+}
+
+export class DesktopUpdateManager {
+  constructor({ version, arch = process.arch, platform = process.platform, cacheRoot, inspect, compare, source, fetchImpl = fetch,
+    verifyImage = (path, signal) => promisify(execFile)('/usr/bin/hdiutil', ['verify', path], { timeout: 120000, signal, maxBuffer: 1024 * 1024 }) }) {
+    Object.assign(this, { version, arch, platform, cacheRoot, inspect, compare, source, fetchImpl, verifyImage })
+    this.state = { phase: 'idle', currentVersion: version, arch, message: '点击检查更新', available: false, received: 0, total: 0 }
+  }
+  snapshot() { return { ...this.state } }
+  get busy() { return !!this.controller }
+  cancel() { this.controller?.abort() }
+  async check() {
+    if (this.busy) return this.snapshot()
+    const controller = this.controller = new AbortController()
+    this.file = undefined; this.release = undefined
+    this.state = { ...this.state, phase: 'checking', available: false, latestVersion: undefined, notes: '', releasePageUrl: undefined, message: '正在检查 GitHub 稳定版本…', error: undefined, received: 0, total: 0 }
+    try {
+      const release = await this.inspect(this.source, this.fetchImpl, controller.signal)
+      controller.signal.throwIfAborted()
+      this.release = release
+      const version = release.manifest.webVersion
+      this.asset = release.assets.find(asset => asset.name === `Yaoyao-${version}-${this.arch}.dmg`)
+      this.checksum = release.assets.find(asset => asset.name === 'SHA256SUMS.txt')
+      const newer = this.compare(version, this.version) > 0
+      const supported = this.platform === 'darwin' && ['arm64', 'x64'].includes(this.arch)
+        && this.asset?.size > 0 && this.asset.size <= 2 * 1024 ** 3 && this.checksum?.size <= 65536
+      this.state = { ...this.state, phase: 'checked', latestVersion: version, notes: release.notes, releasePageUrl: release.releasePageUrl,
+        available: Boolean(newer && supported), total: supported ? this.asset.size : 0,
+        message: !newer ? '当前 App 已是最新版本' : supported ? `发现 App ${version},可下载后手动安装` : '此版本缺少适用的安装包或校验文件,请查看发布页' }
+    } catch (error) { this.failure(error, controller.signal) }
+    finally { this.controller = undefined }
+    return this.snapshot()
+  }
+  failure(error, signal) {
+    this.state = { ...this.state, phase: signal.aborted ? 'cancelled' : 'failed',
+      message: signal.aborted ? '已取消,可重新检查或下载' : '操作未完成,可重试', error: signal.aborted ? undefined : error.message }
+  }
+  async download() {
+    if (this.busy || !this.state.available || !this.asset || !this.checksum) return this.snapshot()
+    const controller = this.controller = new AbortController()
+    const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(10 * 60 * 1000)])
+    const directory = join(this.cacheRoot, this.release.manifest.webVersion)
+    const path = join(directory, this.asset.name), partial = join(directory, `${randomUUID()}.part`)
+    this.file = undefined
+    this.state = { ...this.state, phase: 'downloading', received: 0, error: undefined, message: '正在下载并核对安装包…' }
+    try {
+      await mkdir(directory, { recursive: true, mode: 0o700 })
+      const sums = await githubDownload(this.checksum.url, this.fetchImpl, signal)
+      const chunks = []; let size = 0
+      for await (const chunk of sums.body) {
+        size += chunk.length
+        if (size > 65536 || size > this.checksum.size) throw new Error('校验文件大小无效')
+        chunks.push(Buffer.from(chunk))
+      }
+      if (size !== this.checksum.size) throw new Error('校验文件下载不完整')
+      const bytes = Buffer.concat(chunks)
+      assertDigest(this.checksum.digest, createHash('sha256').update(bytes).digest('hex'))
+      const matches = bytes.toString('utf8').split(/\r?\n/).map(line => line.match(/^([a-fA-F0-9]{64}) [ *](.+)$/))
+        .filter(match => match && match[2] === this.asset.name)
+      if (matches.length !== 1) throw new Error('校验文件缺少唯一的安装包 SHA-256')
+      const expected = matches[0][1].toLowerCase()
+      assertDigest(this.asset.digest, expected)
+      let cached
+      try { cached = await fileHash(path, signal) } catch (error) { if (error.code !== 'ENOENT') throw error }
+      if (!cached || cached.size !== this.asset.size || cached.hash !== expected) {
+        const response = await githubDownload(this.asset.url, this.fetchImpl, signal)
+        const file = await open(partial, 'wx', 0o600), hash = createHash('sha256')
+        let received = 0
+        try {
+          for await (const chunk of response.body) {
+            signal.throwIfAborted(); received += chunk.length
+            if (received > this.asset.size) throw new Error('安装包大小超过发布声明')
+            hash.update(chunk)
+            let offset = 0
+            while (offset < chunk.length) offset += (await file.write(chunk, offset, chunk.length - offset)).bytesWritten
+            this.state = { ...this.state, received }
+          }
+          if (received !== this.asset.size || hash.digest('hex') !== expected) throw new Error('安装包大小或 SHA-256 校验失败')
+        } finally { await file.close() }
+        this.state = { ...this.state, phase: 'verifying', message: '正在验证 DMG 完整性…' }
+        await this.verifyImage(partial, signal); signal.throwIfAborted()
+        await rename(partial, path)
+      } else {
+        this.state = { ...this.state, phase: 'verifying', message: '正在重新验证缓存安装包…' }
+        await this.verifyImage(path, signal); signal.throwIfAborted()
+      }
+      this.file = { path, hash: expected, size: this.asset.size }
+      this.state = { ...this.state, phase: 'ready', received: this.asset.size, message: '校验通过。打开安装包后手动拖入“应用程序”完成安装。' }
+    } catch (error) { this.failure(error, controller.signal) }
+    finally { await rm(partial, { force: true }).catch(() => {}); this.controller = undefined }
+    return this.snapshot()
+  }
+  async verifiedFile() {
+    if (this.busy || !this.file || this.state.phase !== 'ready') throw new Error('请先下载并验证安装包')
+    const actual = await fileHash(this.file.path)
+    if (actual.hash !== this.file.hash || actual.size !== this.file.size) {
+      this.file = undefined; this.state = { ...this.state, phase: 'failed', message: '缓存安装包已改变,请重新下载' }
+      throw new Error(this.state.message)
+    }
+    return this.file.path
+  }
+}

+ 104 - 0
desktop/update-manager.test.mjs

@@ -0,0 +1,104 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { mkdtemp, rm, readdir, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { createHash } from 'node:crypto'
+import { DesktopUpdateManager, githubDownload } from './update-manager.mjs'
+
+const hash = bytes => createHash('sha256').update(bytes).digest('hex')
+const prefix = 'https://github.com/Lsamien/hermes-yaoyao/releases/download/v0.4.2/'
+const name = 'Yaoyao-0.4.2-arm64.dmg', data = Buffer.from('fixture-image')
+async function fixture(overrides = {}) {
+  const home = await mkdtemp(join(tmpdir(), 'yaoyao-update-unit-'))
+  const sums = Buffer.from(`${hash(data)}  ${name}\n`)
+  const release = { manifest: { webVersion: '0.4.2' }, commit: 'a'.repeat(40), notes: 'Notes', releasePageUrl: 'https://github.com/Lsamien/hermes-yaoyao/releases/tag/v0.4.2', assets: [
+    { name, size: data.length, digest: `sha256:${hash(data)}`, url: prefix + name },
+    { name: 'SHA256SUMS.txt', size: sums.length, digest: `sha256:${hash(sums)}`, url: prefix + 'SHA256SUMS.txt' },
+  ] }
+  const calls = [], verified = []
+  const manager = new DesktopUpdateManager({ version: '0.4.1', arch: 'arm64', platform: 'darwin', cacheRoot: home,
+    source: 'https://github.com/Lsamien/hermes-yaoyao.git', inspect: async () => release,
+    compare: (a, b) => Number(a.split('.').at(-1)) - Number(b.split('.').at(-1)),
+    fetchImpl: async url => { calls.push(url); return new Response(url.endsWith('.dmg') ? data : sums) },
+    verifyImage: async path => { verified.push(path) }, ...overrides })
+  return { manager, release, home, calls, verified, clean: () => rm(home, { recursive: true, force: true }) }
+}
+test('downloads, checks both digests and image, reuses verified cache and rejects later tampering', async () => {
+  const f = await fixture()
+  try {
+    assert.equal((await f.manager.check()).available, true)
+    assert.equal((await f.manager.download()).phase, 'ready')
+    const path = await f.manager.verifiedFile()
+    assert.equal(f.verified.length, 1)
+    await f.manager.download()
+    assert.equal(f.calls.filter(url => url.endsWith('.dmg')).length, 1)
+    assert.equal(f.verified.length, 2)
+    await writeFile(path, 'tampered')
+    await assert.rejects(f.manager.verifiedFile(), /改变/)
+    assert.equal(f.manager.snapshot().phase, 'failed')
+  } finally { await f.clean() }
+})
+test('same version with a different commit and unsupported architectures never offer an install', async () => {
+  const f = await fixture({ version: '0.4.2' })
+  try {
+    assert.equal((await f.manager.check()).available, false)
+    f.manager.version = '0.4.1'; f.manager.arch = 'x64'
+    assert.equal((await f.manager.check()).available, false)
+    assert.match(f.manager.snapshot().message, /缺少/)
+    assert.equal(f.calls.length, 0)
+  } finally { await f.clean() }
+})
+test('corrupt or missing checksum, wrong size, and failed image validation never expose a file', async () => {
+  for (const failure of ['digest', 'size', 'checksum', 'image']) {
+    const f = await fixture()
+    try {
+      await f.manager.check()
+      if (failure === 'digest') f.release.assets[0].digest = 'sha256:' + '0'.repeat(64)
+      if (failure === 'size') f.release.assets[0].size++
+      if (failure === 'checksum') f.manager.fetchImpl = async () => new Response('wrong-checksum')
+      if (failure === 'image') f.manager.verifyImage = async () => { throw new Error('invalid dmg') }
+      assert.equal((await f.manager.download()).phase, 'failed')
+      await assert.rejects(f.manager.verifiedFile())
+      assert.deepEqual(await readdir(join(f.home, '0.4.2')), [])
+    } finally { await f.clean() }
+  }
+})
+test('cancels a pending download, excludes duplicate work and permits retry', async () => {
+  const f = await fixture()
+  try {
+    await f.manager.check()
+    const original = f.manager.fetchImpl
+    let started
+    const entered = new Promise(resolve => { started = resolve })
+    f.manager.fetchImpl = async (url, options) => {
+      if (!url.endsWith('.dmg')) return original(url, options)
+      started()
+      return new Promise((resolve, reject) => options.signal.addEventListener('abort', () => reject(new Error('aborted')), { once: true }))
+    }
+    const pending = f.manager.download(); await entered
+    assert.equal((await f.manager.download()).phase, 'downloading')
+    f.manager.cancel(); assert.equal((await pending).phase, 'cancelled')
+    assert.deepEqual(await readdir(join(f.home, '0.4.2')), [])
+    f.manager.fetchImpl = original
+    assert.equal((await f.manager.download()).phase, 'ready')
+  } finally { await f.clean() }
+})
+test('a failed check clears stale update availability and reports a retryable error', async () => {
+  const f = await fixture()
+  try {
+    await f.manager.check()
+    f.manager.inspect = async () => { throw new Error('GitHub rate limited') }
+    const state = await f.manager.check()
+    assert.equal(state.available, false); assert.equal(state.phase, 'failed')
+    assert.match(state.error, /GitHub/)
+  } finally { await f.clean() }
+})
+test('follows GitHub CDN redirects but rejects insecure or foreign hosts', async () => {
+  let calls = 0
+  const allowed = async () => ++calls === 1 ? new Response(null, { status: 302, headers: { Location: 'https://release-assets.githubusercontent.com/fixture' } }) : new Response(data)
+  assert.equal((await githubDownload(prefix + name, allowed)).status, 200)
+  for (const location of ['http://github.com/file', 'https://evil.example/file', 'http://127.0.0.1/file']) {
+    await assert.rejects(githubDownload(prefix + name, async () => new Response(null, { status: 302, headers: { Location: location } })), /不受信任/)
+  }
+})

+ 4 - 0
desktop/update-preload.cjs

@@ -0,0 +1,4 @@
+const { contextBridge, ipcRenderer } = require('electron')
+contextBridge.exposeInMainWorld('yaoyaoUpdate', Object.freeze(Object.fromEntries(
+  ['state', 'check', 'download', 'cancel', 'open', 'folder', 'release'].map(action => [action, () => ipcRenderer.invoke(`desktop-update:${action}`)]),
+)))

+ 90 - 0
desktop/update-window.test.mjs

@@ -0,0 +1,90 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { mkdtemp, mkdir, writeFile, readFile, rm } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join, resolve } from 'node:path'
+import { createServer } from 'node:http'
+import { execFile } from 'node:child_process'
+import { promisify } from 'node:util'
+import { createHash } from 'node:crypto'
+import { _electron as electron, expect } from '@playwright/test'
+
+const root = resolve(import.meta.dirname, '..')
+test('native update window works without a Web service, verifies a real DMG and denies the boot renderer', { timeout: 90000 }, async () => {
+  const home = await mkdtemp(join(tmpdir(), 'yaoyao-update-window-'))
+  const blocked = createServer((req, res) => { res.setHeader('Content-Type', 'application/json'); res.end('{"ok":true}') })
+  await new Promise(done => blocked.listen(0, '127.0.0.1', done))
+  let app
+  try {
+    const folder = join(home, 'image-source'); await mkdir(folder); await writeFile(join(folder, 'fixture.txt'), 'DMG integrity fixture')
+    const image = join(home, 'fixture.dmg')
+    await promisify(execFile)('/usr/bin/hdiutil', ['create', '-srcfolder', folder, '-volname', 'YaoYao Update Test', '-format', 'UDZO', image])
+    const bytes = await readFile(image), digest = createHash('sha256').update(bytes).digest('hex')
+    const current = JSON.parse(await readFile(join(root, 'release.json'), 'utf8')).webVersion
+    const version = current.split('.').map((part, i) => i === 2 ? String(Number(part) + 1) : part).join('.')
+    const name = `Yaoyao-${version}-arm64.dmg`, sums = `${digest}  ${name}\n`
+    app = await electron.launch({ ...(process.env.DESKTOP_TEST_EXECUTABLE ? { executablePath: process.env.DESKTOP_TEST_EXECUTABLE } : {}),
+      args: process.env.DESKTOP_TEST_EXECUTABLE ? [] : [root], cwd: root,
+      env: { ...process.env, HERMES_YAOYAO_DESKTOP_TEST_HOME: home, HERMES_YAOYAO_DESKTOP_TEST_SYNC: '1', HERMES_YAOYAO_DESKTOP_PORT: String(blocked.address().port), HERMES_YAOYAO_UPSTREAM: 'http://127.0.0.1:1' } })
+    const boot = await app.firstWindow()
+    await boot.locator('#status[role="alert"]').waitFor()
+    assert.equal(await boot.evaluate(() => typeof window.yaoyaoUpdate), 'undefined')
+    // Even another renderer carrying the real bridge cannot acquire update privileges.
+    const denied = await app.evaluate(async ({ BrowserWindow }, preload) => {
+      const outsider = new BrowserWindow({ show: false, webPreferences: { sandbox: true, contextIsolation: true, preload } })
+      try {
+        await outsider.loadURL('data:text/html,<html><body>Untrusted renderer</body></html>')
+        return await outsider.webContents.executeJavaScript("window.yaoyaoUpdate.check().then(()=>false,()=>true)")
+      } finally { outsider.destroy() }
+    }, join(root, 'desktop/update-preload.cjs'))
+    assert.equal(denied, true)
+    const opened = app.waitForEvent('window')
+    await app.evaluate(({ net, shell, Menu }, fixture) => {
+      const tag = `v${fixture.version}`, api = 'https://api.github.com/repos/Lsamien/hermes-yaoyao'
+      const prefix = `https://github.com/Lsamien/hermes-yaoyao/releases/download/${tag}/`
+      const commit = 'a'.repeat(40)
+      net.fetch = async url => {
+        const path = String(url)
+        if (path === `${api}/releases/latest`) return Response.json({ draft: false, prerelease: false, tag_name: tag, body: '下载、校验、手动安装。', assets: [
+          { name: fixture.name, size: fixture.size, state: 'uploaded', digest: `sha256:${fixture.digest}`, browser_download_url: prefix + fixture.name },
+          { name: 'SHA256SUMS.txt', size: fixture.sums.length, state: 'uploaded', browser_download_url: prefix + 'SHA256SUMS.txt' },
+        ] })
+        if (path === `${api}/git/ref/tags/${tag}`) return Response.json({ object: { type: 'commit', sha: commit } })
+        if (path === `https://raw.githubusercontent.com/Lsamien/hermes-yaoyao/${commit}/release.json`) return Response.json({ schemaVersion: 1, releaseVersion: fixture.version, webVersion: fixture.version, gitTag: tag })
+        if (path === prefix + 'SHA256SUMS.txt') return new Response(fixture.sums)
+        if (path === prefix + fixture.name) return new Response(Buffer.from(fixture.data, 'base64'))
+        throw new Error('Unexpected update request')
+      }
+      shell.openPath = async path => { process.env.YAOYAO_TEST_OPENED_UPDATE = path; return '' }
+      Menu.getApplicationMenu().getMenuItemById('desktop-update-check').click()
+    }, { version, name, sums, data: bytes.toString('base64'), size: bytes.length, digest })
+    const page = await opened
+    await page.getByRole('button', { name: '下载 DMG', exact: true }).waitFor()
+    await page.getByRole('button', { name: '下载 DMG', exact: true }).click()
+    await page.getByRole('button', { name: '打开安装包', exact: true }).waitFor({ timeout: 30000 })
+    await page.getByRole('button', { name: '检查更新', exact: true }).focus()
+    await page.keyboard.press('Tab')
+    await expect(page.getByRole('button', { name: '打开安装包', exact: true })).toBeFocused()
+    assert.equal(await app.evaluate(() => process.env.YAOYAO_TEST_OPENED_UPDATE), undefined)
+    await mkdir(join(root, 'test-results/desktop'), { recursive: true })
+    await page.emulateMedia({ colorScheme: 'light' })
+    await page.screenshot({ path: join(root, 'test-results/desktop/github-update-light.png') })
+    await page.emulateMedia({ colorScheme: 'dark', reducedMotion: 'reduce' })
+    await page.setViewportSize({ width: 380, height: 620 })
+    assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true)
+    await page.screenshot({ path: join(root, 'test-results/desktop/github-update-dark.png') })
+    await page.getByRole('button', { name: '打开安装包', exact: true }).click()
+    await expect.poll(() => app.evaluate(() => process.env.YAOYAO_TEST_OPENED_UPDATE)).toBe(join(home, 'updates', 'desktop-downloads', version, name))
+    await app.evaluate(({ net }) => { net.fetch = async () => new Response('', { status: 403 }) })
+    await page.getByRole('button', { name: '检查更新', exact: true }).click()
+    await expect(page.getByRole('alert')).toContainText('GitHub 请求受限')
+    await expect(page.getByRole('button', { name: '打开安装包', exact: true })).toBeHidden()
+    assert.equal(blocked.listening, true)
+    await page.keyboard.press('Escape').catch(error => { if (!page.isClosed()) throw error })
+    await expect.poll(() => page.isClosed()).toBe(true)
+  } finally {
+    await app?.close().catch(() => {})
+    await new Promise(done => blocked.close(done))
+    await rm(home, { recursive: true, force: true })
+  }
+})

+ 3 - 0
desktop/update.css

@@ -0,0 +1,3 @@
+:root{color-scheme:light dark;--surface:#f7f7f7;--card:#fff;--text:#202124;--muted:#5d626a;--line:#d3d6dc;--accent:#1765c1;--on-accent:#fff;--danger:#a32626}
+@media(prefers-color-scheme:dark){:root{--surface:#1c1d20;--card:#26282c;--text:#f3f4f6;--muted:#b6bdc9;--line:#505663;--accent:#8bbbff;--on-accent:#13233a;--danger:#ffaaaa}}
+*{box-sizing:border-box}body{margin:0;background:var(--surface);color:var(--text);font:14px/1.6 system-ui,-apple-system,sans-serif}main{max-width:720px;margin:auto;padding:28px}h1{font-size:25px;margin:0 0 6px}p{margin:8px 0}.eyebrow,.muted,header>p,footer{color:var(--muted)}.eyebrow{font-size:12px;letter-spacing:.04em}.versions{display:grid;grid-template-columns:1fr 1fr;gap:16px;padding:18px 0;margin-top:14px;border-block:1px solid var(--line)}.versions div{display:grid;gap:5px}.versions span{color:var(--muted)}.versions strong{font-size:19px}#status{min-height:24px}#error{color:var(--danger);overflow-wrap:anywhere}progress{width:100%;accent-color:var(--accent)}.actions{display:flex;flex-wrap:wrap;gap:8px;margin:16px 0}button{min-height:40px;padding:8px 14px;border:1px solid var(--line);border-radius:8px;background:var(--card);color:var(--text);font:inherit;cursor:pointer}button:hover:not(:disabled){border-color:var(--accent)}button:disabled{opacity:.55;cursor:default}.primary{background:var(--accent);color:var(--on-accent);border-color:var(--accent)}button:focus-visible,summary:focus-visible{outline:2px solid var(--accent);outline-offset:3px}.link{padding:6px 0;border:0;background:transparent;color:var(--accent)}details{border-block:1px solid var(--line);padding:12px 0}summary{cursor:pointer}pre{font:inherit;white-space:pre-wrap;overflow-wrap:anywhere;max-height:220px;overflow:auto}footer{font-size:12px;margin-top:16px}[hidden]{display:none!important}@media(max-width:420px){main{padding:20px}.versions{gap:8px}.versions strong{font-size:17px}}

+ 13 - 0
desktop/update.html

@@ -0,0 +1,13 @@
+<!doctype html>
+<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
+<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self'; style-src 'self'">
+<title>App 更新 · 夭夭</title><link rel="stylesheet" href="update.css"></head>
+<body><main>
+<header><p class="eyebrow">夭夭 · macOS</p><h1>App 更新</h1><p>从 GitHub 下载,校验后手动安装。</p></header>
+<section class="versions" aria-label="版本信息"><div><span>当前版本</span><strong id="current">—</strong></div><div><span>最新版本</span><strong id="latest">待检查</strong></div></section>
+<p id="status" role="status" aria-live="polite">正在检查…</p><p id="error" role="alert" hidden></p>
+<progress id="progress" aria-label="安装包下载进度" hidden></progress><p id="bytes" class="muted"></p>
+<div class="actions"><button id="check">检查更新</button><button id="download" class="primary" disabled>下载 DMG</button><button id="cancel" hidden>取消</button><button id="open" class="primary" hidden>打开安装包</button><button id="folder" hidden>打开所在文件夹</button></div>
+<details id="notes-section" hidden><summary>发布说明</summary><pre id="notes"></pre></details>
+<footer><button id="release" class="link">查看 GitHub 发布页</button><p>下载不会停止聊天或后台服务。安装时将“夭夭”拖入“应用程序”;首次启动会同步较旧的本机 Web。</p><p>当前桌面包为开发签名,未公证。请遵循 macOS 安装提示。</p></footer>
+</main><script src="update.js"></script></body></html>

+ 36 - 0
desktop/update.js

@@ -0,0 +1,36 @@
+const byId = id => document.getElementById(id)
+let closed = false
+let localError = '', previousPhase = ''
+const sizeLabel = value => value < 1048576 ? `${(value / 1024).toFixed(1)} KB` : `${(value / 1048576).toFixed(1)} MB`
+function render(state) {
+  const busy = ['checking', 'downloading', 'verifying'].includes(state.phase)
+  const ready = state.phase === 'ready'
+  byId('current').textContent = `${state.currentVersion} · ${state.arch}`
+  byId('latest').textContent = state.latestVersion || (state.phase === 'failed' ? '检查失败' : '待检查')
+  byId('status').textContent = state.message
+  byId('error').hidden = !(state.error || localError); byId('error').textContent = state.error || localError
+  byId('check').disabled = busy
+  byId('download').disabled = busy || !state.available; byId('download').hidden = ready
+  byId('download').textContent = state.phase === 'failed' || state.phase === 'cancelled' ? '重试下载' : '下载 DMG'
+  byId('cancel').hidden = !busy
+  byId('open').hidden = !ready; byId('folder').hidden = !ready
+  byId('progress').hidden = !['downloading', 'verifying'].includes(state.phase)
+  byId('progress').max = state.total || 1; byId('progress').value = state.received || 0
+  byId('bytes').textContent = state.received ? `${sizeLabel(state.received)} / ${sizeLabel(state.total)}` : ''
+  byId('notes-section').hidden = !state.notes; byId('notes').textContent = state.notes || ''
+  if (state.phase !== previousPhase && ['download', 'cancel'].includes(document.activeElement?.id)) {
+    if (ready) byId('open').focus()
+    else if (!busy) byId(state.available ? 'download' : 'check').focus()
+  }
+  previousPhase = state.phase
+}
+async function action(name) {
+  if (name !== 'state') localError = ''
+  try { const state = await window.yaoyaoUpdate[name](); if (state && !closed) render(state) }
+  catch (error) { if (!closed) { localError = error.message; byId('error').hidden = false; byId('error').textContent = localError } }
+}
+for (const name of ['check', 'download', 'cancel', 'open', 'folder', 'release']) byId(name).addEventListener('click', () => void action(name))
+const timer = setInterval(() => void action('state'), 500)
+window.addEventListener('unload', () => { closed = true; clearInterval(timer) })
+document.addEventListener('keydown', event => { if (event.key === 'Escape') window.close() })
+void action('check')

+ 2 - 0
docker.env.example

@@ -10,6 +10,8 @@ HERMES_YAOYAO_PUBLISHED_PORT=15300
 # Docker Desktop 可用这个地址访问宿主机;Linux 上的 9119 还必须监听容器可达的宿主机地址。
 HERMES_YAOYAO_UPSTREAM=http://host.docker.internal:9119
 HERMES_YAOYAO_CHAT_CACHE_MODE=prefer-local
+# 官方稳定发布源;自定义 Git 仓库可覆盖。容器仍由部署者重建升级。
+HERMES_YAOYAO_RELEASE_SOURCE=https://github.com/Lsamien/hermes-yaoyao.git
 # 首次登录后在 Web 系统设置中保存 Hermes 连接凭据。
 # 使用 compose.desktops.yaml 可同时部署固定数量的共享桌面容器。
 # 桌面数量以 Compose 为准,Web 仅连接已有桌面;详见 docs/docker-install.md。

+ 29 - 0
docs/github-updates.md

@@ -0,0 +1,29 @@
+# GitHub 版本升级
+
+官方发布源为 `https://github.com/Lsamien/hermes-yaoyao.git`。检查使用公开稳定 Release,不需要 GitHub 账号、访问令牌或安装 GitHub CLI;尚未发布的标签和预发布版本不会被选为更新。
+
+## Web
+
+进入设置中的“更新与回滚”即可检查版本,页面显示实际发布源与发布说明。检查会解析标签提交,并从固定提交读取 `release.json`;安装时再次校验下载提交。GitHub 限流、网络失败或清单异常会显示错误,不能据此宣称已是最新版。
+
+macOS 独立 Web 继续使用现有升级事务:构建新版本、等待任务空闲、备份、切换并验证,失败恢复。回滚仍保留已写入的新数据,数据库不兼容时拒绝降级。检查和升级不依赖 9119;只以 Web 自身健康判断升级结果。Docker 显示最新版本与发布页,由部署者重新构建镜像和容器。
+
+通过 `HERMES_YAOYAO_RELEASE_SOURCE` 可指定自定义仓库;自定义源(包括其他 GitHub 仓库)保留现有 Git 标签流程。官方 GitHub 仓库必须提供稳定 Release 和版本一致的 `release.json`。同一语义版本在 GitHub 与内网拥有不同提交编号,不会单凭编号触发升级。
+
+## macOS App
+
+使用“夭夭 → 检查 App 更新…”打开独立窗口。它直接连接官方 GitHub,即使 Web 或 9119 未启动也可使用,不受 Web 自定义源设置影响。
+
+新版提供当前架构的 `Yaoyao-{版本}-{架构}.dmg` 和 `SHA256SUMS.txt` 后,窗口显示下载按钮。下载可取消或重试,不会停止聊天、Runner 或后台服务。文件写入 Web 数据目录下的 `updates/desktop-downloads`,通过大小、SHA-256 和 DMG 完整性检查后才提供“打开安装包”。GitHub 附件摘要存在时也必须一致,缓存重新使用和打开前会核对文件。
+
+打开 DMG 后手动拖入“应用程序”安装。不会自动覆盖正在运行的 App、申请提权或自动重启;开发签名包尚未公证,请遵循 macOS 提示。安装后首次启动仍只向上同步较旧的本机 Web,保留较新的独立服务。
+
+下载和打开接口只授权给本地更新窗口,远程页面、普通聊天页面和启动页不能调用。发布说明按纯文本显示,不执行远端 HTML 或脚本。
+
+## 旧安装迁移
+
+加载新版本后,空发布源以及官方旧仓库 `git.samien.cn/samien/hermes-yaoyao`、`192.168.153.8:3000/samien/hermes-yaoyao` 的无凭据 HTTP(S) 地址会映射到 GitHub;兼容 `.git` 后缀、尾部斜杠和既有 `git@git.samien.cn:samien/hermes-yaoyao.git` 地址。其他用户自定义仓库保持原样。
+
+运行时先使用归一化后的源;下次生成或更新本应用管理的 LaunchAgent 配置时持久化,保留地址、TLS、上游等其他环境变量。不会直接改写用户维护的环境文件。
+
+旧程序不会凭空获得迁移能力:首次需手动安装包含此功能的新 App,或更新源码并重启。既有发布标签和安装包不会被改写。

+ 2 - 0
docs/macos-app.md

@@ -19,6 +19,8 @@ App、Web 服务、Vue 界面和 Runner 同仓维护。正式 App 在每个构
 
 ## 更新与回退
 
+App 新增“检查 App 更新…”窗口,直接从官方 GitHub 检查稳定版本、下载并校验 DMG,由用户手动安装。它不依赖 Web 或 9119 在线,详见 [GitHub 版本升级](github-updates.md)。
+
 正式 App 的同步在安装/更新后的**首次启动**执行;复制 App 文件本身不执行服务操作。详见 [App 与 Web 版本同步](app-web-sync.md)。
 
 - 同步仅针对当前用户、相同数据目录和端口的本机 LaunchAgent;保留已有服务的地址、TLS、上游及通知配置。

+ 1 - 0
electron-builder.yml

@@ -23,6 +23,7 @@ extraResources:
       - guest_proxy.py
       - release.json
       - build-info.json
+      - github-release.mjs
       - keychain-helper
       - ui/**/*
       - web-service/**/*

+ 2 - 0
scripts/build-desktop.mjs

@@ -14,6 +14,8 @@ await build({ entryPoints: [resolve(root, 'src/server/index.ts')], outfile: reso
   define: { 'process.env.NODE_ENV': '"production"' }, sourcemap: true,
 })
 await buildRunner(out)
+await build({ entryPoints: [resolve(root, 'src/server/githubReleases.ts')], outfile: resolve(out, 'github-release.mjs'),
+  bundle: true, platform: 'node', format: 'esm', target: 'node24' })
 const swiftTarget=process.arch==='arm64'?'arm64-apple-macosx12.0':'x86_64-apple-macosx12.0'
 execFileSync('xcrun',['swiftc','-O','-target',swiftTarget,resolve(root,'desktop/keychain-helper.swift'),'-o',resolve(out,'keychain-helper')],{stdio:'inherit'})
 execFileSync('xcrun',['swiftc','-O','-target',swiftTarget,'-D','DEVELOPMENT_HELPER',resolve(root,'desktop/keychain-helper.swift'),'-o',resolve(out,'keychain-helper-dev')],{stdio:'inherit'})

+ 2 - 0
src/client/api/systemUpdate.ts

@@ -32,6 +32,8 @@ export interface UpdateJob {
 }
 
 export interface SystemUpdateStatus {
+  releaseSource?: string
+  releasePageUrl?: string
   current: ReleaseManifest
   build?: { commit: string; buildNumber: number; dirty: boolean; artifactDigest?: string }
   installationMode: 'source' | 'release' | 'desktop'

+ 8 - 3
src/client/components/app/SystemUpdatePanel.vue

@@ -18,6 +18,7 @@ const status = ref<SystemUpdateStatus>()
 const job = ref<UpdateJob>()
 const busy = ref(false)
 const checking = ref(false)
+const remoteChecked = ref(false)
 const error = ref('')
 const trackingTimedOut = ref(false)
 const operationStarting = ref(false)
@@ -37,9 +38,11 @@ async function refresh(checkRemote = false): Promise<SystemUpdateStatus | undefi
   checking.value = checkRemote
   try {
     status.value = checkRemote ? await checkSystemUpdate() : await systemUpdateStatus()
+    if (checkRemote) remoteChecked.value = true
     if (status.value.job) job.value = status.value.job
     return status.value
   } catch (cause) {
+    if (checkRemote) remoteChecked.value = false
     error.value = cause instanceof Error ? cause.message : '无法读取系统版本'
   } finally {
     checking.value = false
@@ -134,6 +137,7 @@ watch(() => props.active, active => {
     return
   }
   job.value = undefined
+  remoteChecked.value = false
   busy.value = true
   void loadAndResume(token)
     .finally(() => { if (token === lifecycleToken) busy.value = false })
@@ -149,9 +153,10 @@ onBeforeUnmount(() => { lifecycleToken += 1; operationStarting.value = false; st
 
     <section v-if="status" class="version-grid" aria-label="版本信息">
       <article><small>当前 Web</small><strong>{{ status.current.webVersion }}</strong></article>
-      <article><small>最新 Web</small><strong>{{ status.latest?.webVersion || status.current.webVersion }}</strong></article>
+      <article><small>最新 Web</small><strong>{{ error ? '检查失败' : !remoteChecked ? '待检查' : status.latest?.webVersion || status.current.webVersion }}</strong></article>
     </section>
 
+    <p v-if="status?.releaseSource" class="mode-note">发布源:{{ status.releaseSource }} <a v-if="status.releasePageUrl" :href="status.releasePageUrl" target="_blank" rel="noopener noreferrer">查看发布说明</a></p>
     <p v-if="status?.installationMode === 'source'" class="mode-note">首次升级会把运行服务迁移到可回滚的版本目录;Git 工作区不会被覆盖。</p>
     <p v-if="status && !status.supported" class="mode-note">{{ status.unsupportedReason }}</p>
 
@@ -164,7 +169,7 @@ onBeforeUnmount(() => { lifecycleToken += 1; operationStarting.value = false; st
       <button v-if="job?.state === 'succeeded' || job?.state === 'rolled_back'" class="quiet-button" type="button" @click="reloadPage"><AppIcon name="refresh" :size="16" />刷新页面</button>
       <button v-else-if="status?.canRollback && !jobRunning" class="quiet-button danger" type="button" :disabled="busy" @click="rollback">回滚上一版本</button>
       <button class="quiet-button" type="button" :disabled="checking || busy || operationStarting || jobRunning" @click="refresh(true)"><AppIcon name="refresh" :size="16" />{{ checking ? '检查中…' : '检查更新' }}</button>
-      <button class="solid-button" type="button" :disabled="!canApply" @click="applyUpdate"><AppIcon name="download" :size="16" />{{ jobRunning ? '升级中…' : status?.updateAvailable ? '升级 Web' : '已是最新版本' }}</button>
+      <button class="solid-button" type="button" :disabled="!canApply || !!error || !remoteChecked" @click="applyUpdate"><AppIcon name="download" :size="16" />{{ jobRunning ? '升级中…' : error ? '暂无法检查' : !remoteChecked ? '待检查更新' : status?.updateAvailable ? '升级 Web' : '已是最新版本' }}</button>
     </footer>
   </section>
 </template>
@@ -181,7 +186,7 @@ onBeforeUnmount(() => { lifecycleToken += 1; operationStarting.value = false; st
 .version-grid article:nth-last-child(-n+2) { border-bottom: 0; }
 .version-grid small { color: var(--text-muted); font-size: 12px; }
 .version-grid strong { font-size: 20px; letter-spacing: -.02em; }
-.mode-note { margin: -4px 0 0; color: var(--text-muted); font-size: 12px; line-height: 1.55; }
+.mode-note { margin: -4px 0 0; color: var(--text-muted); font-size: 12px; line-height: 1.55; overflow-wrap: anywhere; }
 .update-progress { display: flex; gap: 12px; padding: 14px; border: 1px solid var(--line); border-radius: 12px; background: var(--surface-soft); }
 .progress-icon { display: grid; width: 34px; height: 34px; flex: 0 0 34px; place-items: center; border-radius: 50%; background: var(--surface-raised); color: var(--text-secondary); }
 .update-progress > span:last-child { display: grid; gap: 4px; min-width: 0; }

+ 5 - 4
src/server/config.ts

@@ -1,4 +1,5 @@
 import { isIP } from 'node:net'
+import { normalizeReleaseSource } from '../../bin/lib/release-source.mjs'
 import { homedir } from 'node:os'
 import { basename, resolve } from 'node:path'
 import { readFileSync } from 'node:fs'
@@ -61,7 +62,7 @@ export interface ServerConfig {
   chatCacheMode?: ChatCacheMode
 }
 
-export const DEFAULT_YAOYAO_RELEASE_SOURCE = 'https://git.samien.cn/samien/hermes-yaoyao.git'
+export { DEFAULT_RELEASE_SOURCE as DEFAULT_YAOYAO_RELEASE_SOURCE } from '../../bin/lib/release-source.mjs'
 
 function flag(value: string | undefined): boolean {
   return value === '1' || value?.toLowerCase() === 'true'
@@ -96,13 +97,13 @@ function parseUpstream(value: string | undefined): URL {
 }
 
 function parseReleaseSource(value: string | undefined): string {
-  const source = value?.trim() || DEFAULT_YAOYAO_RELEASE_SOURCE
+  const source = normalizeReleaseSource(value)
   if (source.length > 2_048 || /[\u0000-\u001f\u007f]/.test(source)) {
     throw new Error('HERMES_YAOYAO_RELEASE_SOURCE is invalid')
   }
-  if (source.startsWith('https://')) {
+  if (source.startsWith('https://') || source.startsWith('ssh://')) {
     const url = new URL(source)
-    if (url.username || url.password || url.search || url.hash) {
+    if ((url.protocol === 'https:' && url.username) || url.password || url.search || url.hash) {
       throw new Error('HERMES_YAOYAO_RELEASE_SOURCE must not contain credentials, query, or fragment')
     }
   } else if (!source.startsWith('git@') && !source.startsWith('ssh://')) {

+ 69 - 0
src/server/githubReleases.ts

@@ -0,0 +1,69 @@
+import { parseReleaseManifest, type ReleaseManifest } from './releases.js'
+import { normalizeReleaseSource } from '../../bin/lib/release-source.mjs'
+export { DEFAULT_RELEASE_SOURCE, normalizeReleaseSource } from '../../bin/lib/release-source.mjs'
+export { compareReleaseVersions } from './releases.js'
+
+export interface GitHubAsset { name: string; url: string; size: number; digest?: string }
+export interface GitHubRelease {
+  manifest: ReleaseManifest
+  commit: string
+  releasePageUrl: string
+  notes: string
+  assets: GitHubAsset[]
+}
+export function githubRepository(source: string): string | undefined {
+  const match = normalizeReleaseSource(source).match(/^https:\/\/github\.com\/([\w.-]+\/[\w.-]+)\.git$/)
+  return match?.[1]
+}
+export function githubReleasePage(source: string): string | undefined {
+  const repo = githubRepository(source)
+  return repo ? `https://github.com/${repo}/releases` : undefined
+}
+
+/** Public releases require no end-user token, gh installation, or Hermes connection. */
+export async function inspectGitHubRelease(source: string, fetchImpl: typeof fetch = fetch, signal?: AbortSignal): Promise<GitHubRelease> {
+  const repo = githubRepository(source)
+  if (!repo) throw new Error('不是有效的 GitHub 仓库地址')
+  const api = `https://api.github.com/repos/${repo}`
+  async function json(url: string): Promise<any> {
+    let response: Response
+    try {
+      response = await fetchImpl(url, { cache: 'no-store', headers: { Accept: 'application/vnd.github+json' },
+        signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(15_000)]) : AbortSignal.timeout(15_000) })
+    } catch {
+      if (signal?.aborted) throw new Error('检查已取消')
+      throw new Error('无法连接 GitHub 发布源,请检查网络后重试')
+    }
+    if (!response.ok) {
+      if (response.status === 403 || response.status === 429) throw new Error('GitHub 请求受限,请稍后重试')
+      if (response.status === 404) throw new Error('GitHub 仓库尚无稳定发布,或发布文件不存在')
+      throw new Error(`GitHub 发布源返回 HTTP ${response.status}`)
+    }
+    try { return await response.json() } catch { throw new Error('GitHub 发布数据不是有效的 JSON') }
+  }
+  const release = await json(`${api}/releases/latest`)
+  if (release.draft !== false || release.prerelease !== false || !/^v\d+\.\d+\.\d+$/.test(release.tag_name)) {
+    throw new Error('GitHub 最新发布不是有效的稳定版本')
+  }
+  const tag = release.tag_name as string
+  let ref = (await json(`${api}/git/ref/tags/${encodeURIComponent(tag)}`)).object
+  for (let depth = 0; ref?.type === 'tag' && depth < 5; depth++) {
+    if (!/^[a-f0-9]{40}$/.test(ref.sha)) throw new Error('GitHub 标签提交无效')
+    ref = (await json(`${api}/git/tags/${ref.sha}`)).object
+  }
+  if (ref?.type !== 'commit' || !/^[a-f0-9]{40}$/.test(ref.sha)) throw new Error('GitHub 标签提交无效')
+  // Read an immutable commit, never a moving branch or tag URL.
+  const manifest = parseReleaseManifest(await json(`https://raw.githubusercontent.com/${repo}/${ref.sha}/release.json`))
+  if (manifest.gitTag !== tag) throw new Error('GitHub 标签与 release.json 版本不一致')
+  const prefix = `https://github.com/${repo}/releases/download/${tag}/`
+  const assets: GitHubAsset[] = []
+  for (const asset of Array.isArray(release.assets) ? release.assets : []) {
+    if (typeof asset.name !== 'string' || !/^[\w.-]+$/.test(asset.name) || asset.state !== 'uploaded'
+      || !Number.isSafeInteger(asset.size) || asset.size <= 0
+      || asset.browser_download_url !== prefix + asset.name) continue
+    assets.push({ name: asset.name, url: asset.browser_download_url, size: asset.size,
+      ...(typeof asset.digest === 'string' ? { digest: asset.digest } : {}) })
+  }
+  return { manifest, commit: ref.sha, assets, notes: typeof release.body === 'string' ? release.body.slice(0, 64000) : '',
+    releasePageUrl: `https://github.com/${repo}/releases/tag/${tag}` }
+}

+ 11 - 2
src/server/updateManager.ts

@@ -18,6 +18,8 @@ import { join, resolve } from 'node:path'
 import { spawn } from 'node:child_process'
 import type { ServerConfig } from './config.js'
 import { DEFAULT_YAOYAO_RELEASE_SOURCE } from './config.js'
+import { normalizeReleaseSource } from '../../bin/lib/release-source.mjs'
+import { githubReleasePage, inspectGitHubRelease } from './githubReleases.js'
 import {
   compareReleaseVersions,
   parseReleaseManifest,
@@ -63,6 +65,8 @@ interface StoredUpdateJob extends UpdateJob {
 }
 
 export interface SystemUpdateStatus {
+  releaseSource: string
+  releasePageUrl?: string
   current: ReleaseManifest
   build?: BuildIdentity
   installationMode: 'source' | 'release' | 'desktop'
@@ -75,6 +79,7 @@ export interface SystemUpdateStatus {
 }
 
 export interface RemoteRelease {
+  releasePageUrl?: string
   manifest: ReleaseManifest
   commit: string
 }
@@ -147,6 +152,8 @@ function exactTagCommit(output: string): string | undefined {
 }
 
 export async function inspectGitRemote(source: string, current: ReleaseManifest): Promise<RemoteRelease | undefined> {
+  source = normalizeReleaseSource(source)
+  if (source === DEFAULT_YAOYAO_RELEASE_SOURCE) return inspectGitHubRelease(source)
   const output = await run('git', ['ls-remote', '--tags', source, 'refs/tags/v*'])
   const byVersion = new Map<string, { version: string; commit: string; peeled: boolean }>()
   for (const line of output.split('\n')) {
@@ -214,7 +221,7 @@ export class SystemUpdateManager {
     this.updaterPath = resolve(options.updaterPath ?? join(this.projectRoot, 'bin', 'hermes-yaoyao-updater.mjs'))
     this.updateHome = join(config.home, 'updates')
     this.releaseRoot = resolve(config.releaseRoot ?? join(homedir(), '.local', 'share', 'hermes-yaoyao'))
-    this.releaseSource = config.releaseSource ?? DEFAULT_YAOYAO_RELEASE_SOURCE
+    this.releaseSource = normalizeReleaseSource(config.releaseSource ?? DEFAULT_YAOYAO_RELEASE_SOURCE)
     this.inspectRemote = options.inspectRemote ?? inspectGitRemote
     this.platform = options.platform ?? process.platform
     this.desktopOwned = options.desktopOwned ?? process.env.HERMES_YAOYAO_DESKTOP === '1'
@@ -265,6 +272,8 @@ export class SystemUpdateManager {
     const job = this.latestJob()
     return {
       current,
+      releaseSource: this.releaseSource,
+      releasePageUrl: githubReleasePage(this.releaseSource),
       build: readBuildIdentity(this.projectRoot),
       installationMode: this.desktopOwned ? 'desktop' : this.projectRoot.startsWith(`${this.releaseRoot}/`) ? 'release' : 'source',
       latest,
@@ -280,7 +289,7 @@ export class SystemUpdateManager {
     const current = this.currentManifest()
     if (this.desktopOwned) return this.status()
     const latest = await this.inspectRemote(this.releaseSource, current)
-    return this.status(latest?.manifest)
+    return { ...this.status(latest?.manifest), releasePageUrl: latest?.releasePageUrl ?? githubReleasePage(this.releaseSource) }
   }
 
   private acquire(jobID: string): void {

+ 11 - 0
tests/client/systemUpdatePanel.test.ts

@@ -60,6 +60,17 @@ afterEach(() => {
 })
 
 describe('SystemUpdatePanel', () => {
+  it('shows GitHub failures without claiming the installed version is latest', async () => {
+    api.systemUpdateStatus.mockResolvedValue({ ...readyStatus, releaseSource: 'https://github.com/Lsamien/hermes-yaoyao.git', releasePageUrl: 'https://github.com/Lsamien/hermes-yaoyao/releases' })
+    api.checkSystemUpdate.mockRejectedValue(new Error('GitHub 请求受限,请稍后重试'))
+    const wrapper = mount(SystemUpdatePanel, { global: { stubs: { AppIcon: true } } })
+    await flushPromises()
+    expect(wrapper.text()).toContain('GitHub 请求受限')
+    expect(wrapper.text()).not.toContain('已是最新版本')
+    expect(wrapper.get('.solid-button').attributes('disabled')).toBeDefined()
+    expect(wrapper.get('a').attributes('href')).toBe('https://github.com/Lsamien/hermes-yaoyao/releases')
+    wrapper.unmount()
+  })
   it('allows Web updates with unknown plugin state and explains the offline boundary', async () => {
     const offlineStatus = { ...readyStatus, installedPluginVersion: undefined, versionsMatch: false, updateAvailable: true,
       latest: { ...manifest, releaseVersion: '0.3.0', webVersion: '0.3.0', gitTag: 'v0.3.0' } }

+ 12 - 1
tests/server/desktopSync.test.ts

@@ -6,7 +6,8 @@ import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { syncDecision, sealRuntime, verifyRuntimePackage } from '../../bin/lib/runtime-release.mjs'
 import { assertRollbackCompatible, backupData, databaseSchema } from '../../bin/lib/service-data.mjs'
-import { currentRelease, reserveUpdate, switchRelease, synchronizeDesktop, stopDesktopService, transitionService, updateMutex, writeJSON, recoverTransition } from '../../bin/lib/service-update.mjs'
+import { currentRelease, reserveUpdate, switchRelease, synchronizeDesktop, stopDesktopService, transitionService, updateMutex, writeJSON, recoverTransition, LaunchAgentService } from '../../bin/lib/service-update.mjs'
+import { execFileSync } from 'node:child_process'
 import { RunnerHub } from '../../src/server/runnerHub'
 
 const homes: string[] = []
@@ -21,6 +22,16 @@ function runtime(root: string, commit = 'b'.repeat(40), version = '0.3.32', ance
 }
 const clean = (commit: string, ancestors: string[] = [], version = '0.3.32') => ({ commit, ancestors, version, dirty: false })
 
+it.skipIf(process.platform !== 'darwin')('persists official source migration while preserving custom sources and other service settings', () => {
+  const home = temporary(), plistPath = join(home, 'fixture.plist')
+  const driver = new LaunchAgentService({ home, port: 18899, releaseRoot: join(home, 'releases'), label: 'fixture', plistPath })
+  for (const [source, expected] of [['https://git.samien.cn/samien/hermes-yaoyao.git', 'https://github.com/Lsamien/hermes-yaoyao.git'], ['https://private.example/fork.git', 'https://private.example/fork.git']]) {
+    driver.writePlist({ Label: 'fixture', EnvironmentVariables: { HERMES_YAOYAO_RELEASE_SOURCE: source, HERMES_YAOYAO_TLS_CERT: '/fixture/cert', CUSTOM_SETTING: 'preserve' } })
+    const restored = JSON.parse(execFileSync('/usr/bin/plutil', ['-convert', 'json', '-o', '-', plistPath], { encoding: 'utf8' }))
+    expect(restored.EnvironmentVariables).toEqual({ HERMES_YAOYAO_RELEASE_SOURCE: expected, HERMES_YAOYAO_TLS_CERT: '/fixture/cert', CUSTOM_SETTING: 'preserve' })
+  }
+})
+
 it('stops only a verified managed service and refuses to race an update',async()=>{
  const home=temporary(),calls:string[]=[]
  const driver={snapshot:()=>({wasRunning:true}),verify:async()=>{calls.push('verify')},stop:async()=>{calls.push('stop')}}

+ 78 - 0
tests/server/githubReleases.test.ts

@@ -0,0 +1,78 @@
+// @vitest-environment node
+import { describe, expect, it, vi, afterEach } from 'vitest'
+import { inspectGitHubRelease, githubRepository } from '../../src/server/githubReleases.js'
+import { inspectGitRemote } from '../../src/server/updateManager.js'
+import { normalizeReleaseSource, DEFAULT_RELEASE_SOURCE } from '../../bin/lib/release-source.mjs'
+import { loadServerConfig } from '../../src/server/config.js'
+import { launchAgentPlist } from '../../bin/hermes-yaoyao.mjs'
+
+const commit = 'a'.repeat(40), tagObject = 'b'.repeat(40)
+const gitCalls = vi.hoisted(() => ({ execFile: vi.fn() }))
+vi.mock('node:child_process', async original => ({ ...await original<typeof import('node:child_process')>(), execFile: gitCalls.execFile }))
+const manifest = { schemaVersion: 1, releaseVersion: '0.4.2', webVersion: '0.4.2', gitTag: 'v0.4.2' }
+const api = 'https://api.github.com/repos/Lsamien/hermes-yaoyao'
+function fixture(overrides: Record<string, unknown> = {}) {
+  const documents: Record<string, unknown> = {
+    [`${api}/releases/latest`]: { draft: false, prerelease: false, tag_name: 'v0.4.2', body: 'Release notes', assets: [
+      { name: 'Yaoyao-0.4.2-arm64.dmg', size: 10, state: 'uploaded', browser_download_url: 'https://github.com/Lsamien/hermes-yaoyao/releases/download/v0.4.2/Yaoyao-0.4.2-arm64.dmg' },
+      { name: 'bad.dmg', size: 10, state: 'uploaded', browser_download_url: 'https://evil.test/bad.dmg' },
+    ] },
+    [`${api}/git/ref/tags/v0.4.2`]: { object: { type: 'tag', sha: tagObject } },
+    [`${api}/git/tags/${tagObject}`]: { object: { type: 'commit', sha: commit } },
+    [`https://raw.githubusercontent.com/Lsamien/hermes-yaoyao/${commit}/release.json`]: manifest,
+    ...overrides,
+  }
+  return vi.fn(async (url: string | URL | Request) => {
+    const value = documents[String(url)]
+    return value instanceof Response ? value : Response.json(value ?? {}, { status: value ? 200 : 404 })
+  }) as unknown as typeof fetch
+}
+afterEach(() => { vi.unstubAllGlobals(); gitCalls.execFile.mockReset() })
+describe('GitHub release source', () => {
+  it('migrates only known official sources and leaves user forks unchanged', () => {
+    for (const source of [undefined, '', 'https://git.samien.cn/samien/hermes-yaoyao.git', 'http://192.168.153.8:3000/samien/hermes-yaoyao/', 'git@git.samien.cn:samien/hermes-yaoyao.git']) expect(normalizeReleaseSource(source)).toBe(DEFAULT_RELEASE_SOURCE)
+    for (const source of ['https://git.samien.cn/other/fork.git', 'https://private.example/repo.git', 'git@private.example:fork.git', 'https://github.com/other/fork.git']) expect(normalizeReleaseSource(source)).toBe(source)
+    expect(normalizeReleaseSource('https://github.com/lsamien/Hermes-Yaoyao')).toBe(DEFAULT_RELEASE_SOURCE)
+    expect(githubRepository('https://github.com/Lsamien/hermes-yaoyao/')).toBe('Lsamien/hermes-yaoyao')
+    expect(loadServerConfig({ HERMES_YAOYAO_RELEASE_SOURCE: 'https://git.samien.cn/samien/hermes-yaoyao.git' }).releaseSource).toBe(DEFAULT_RELEASE_SOURCE)
+    const plist = launchAgentPlist({ environment: { HERMES_YAOYAO_RELEASE_SOURCE: 'https://git.samien.cn/samien/hermes-yaoyao.git' } })
+    expect(plist).toContain(DEFAULT_RELEASE_SOURCE)
+    expect(launchAgentPlist({ environment: { HERMES_YAOYAO_RELEASE_SOURCE: 'https://private.example/fork.git' } })).toContain('https://private.example/fork.git')
+  })
+  it('selects a published stable release, peels annotated tags and reads an immutable manifest', async () => {
+    const fetchImpl = fixture()
+    const result = await inspectGitHubRelease(DEFAULT_RELEASE_SOURCE, fetchImpl)
+    expect(result).toMatchObject({ manifest, commit, notes: 'Release notes', releasePageUrl: 'https://github.com/Lsamien/hermes-yaoyao/releases/tag/v0.4.2' })
+    expect(result.assets).toHaveLength(1)
+    expect(fetchImpl).toHaveBeenCalledWith(`https://raw.githubusercontent.com/Lsamien/hermes-yaoyao/${commit}/release.json`, expect.anything())
+  })
+  it('uses the GitHub adapter instead of cloning or contacting 9119', async () => {
+    const fetchImpl = fixture(); vi.stubGlobal('fetch', fetchImpl)
+    expect(await inspectGitRemote(DEFAULT_RELEASE_SOURCE, manifest as any)).toMatchObject({ manifest, commit })
+    expect(fetchImpl).toHaveBeenCalledTimes(4)
+    expect(gitCalls.execFile).not.toHaveBeenCalled()
+  })
+  it('keeps a custom GitHub fork on the original Git-tag discovery path', async () => {
+    gitCalls.execFile.mockImplementation((_command, _args, _options, callback) => callback(null, '', ''))
+    const fetchImpl = vi.fn(); vi.stubGlobal('fetch', fetchImpl)
+    const source = 'https://github.com/other/fork.git'
+    expect(await inspectGitRemote(source, manifest as any)).toBeUndefined()
+    expect(gitCalls.execFile).toHaveBeenCalledWith('git', ['ls-remote', '--tags', source, 'refs/tags/v*'], expect.anything(), expect.any(Function))
+    expect(fetchImpl).not.toHaveBeenCalled()
+  })
+  it.each([403, 429, 404, 500])('surfaces HTTP %s rather than returning up to date', async status => {
+    await expect(inspectGitHubRelease(DEFAULT_RELEASE_SOURCE, fixture({ [`${api}/releases/latest`]: new Response('', { status }) }))).rejects.toThrow(/GitHub/)
+  })
+  it.each([{ draft: true }, { prerelease: true }, { tag_name: 'v0.4.2-beta.1' }])('rejects non-stable release metadata %s', async patch => {
+    await expect(inspectGitHubRelease(DEFAULT_RELEASE_SOURCE, fixture({ [`${api}/releases/latest`]: { draft: false, prerelease: false, tag_name: 'v0.4.2', ...patch } }))).rejects.toThrow('稳定')
+  })
+  it('rejects mismatching manifests and invalid tags', async () => {
+    await expect(inspectGitHubRelease(DEFAULT_RELEASE_SOURCE, fixture({ [`https://raw.githubusercontent.com/Lsamien/hermes-yaoyao/${commit}/release.json`]: { ...manifest, gitTag: 'v0.4.1' } }))).rejects.toThrow()
+    await expect(inspectGitHubRelease(DEFAULT_RELEASE_SOURCE, fixture({ [`${api}/git/ref/tags/v0.4.2`]: { object: { type: 'tree', sha: commit } } }))).rejects.toThrow('标签提交')
+  })
+  it('reports network failure and cancellation explicitly', async () => {
+    const fetchImpl = vi.fn().mockRejectedValue(new Error('offline'))
+    await expect(inspectGitHubRelease(DEFAULT_RELEASE_SOURCE, fetchImpl)).rejects.toThrow('网络')
+    await expect(inspectGitHubRelease(DEFAULT_RELEASE_SOURCE, fetchImpl, AbortSignal.abort())).rejects.toThrow('取消')
+  })
+})

+ 21 - 9
tests/server/localAuthRoutes.test.ts

@@ -3,14 +3,24 @@ import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import request from 'supertest'
 import { afterEach, describe, expect, it } from 'vitest'
-import { createApplication, createNodeServer, type ApplicationRuntime, type NodeServerRuntime } from '../../src/server/app.js'
+import { createApplication, type ApplicationRuntime } from '../../src/server/app.js'
+import type { Server } from 'node:http'
 import type { ServerConfig } from '../../src/server/config.js'
 
 const roots: string[] = []
 const runtimes: ApplicationRuntime[] = []
-const nodeRuntimes: NodeServerRuntime[] = []
+const servers: Server[] = []
+async function listen(runtime: ApplicationRuntime) {
+  // Keep one listener for the whole scenario. Repeated implicit Supertest
+  // listen/close cycles can race pooled HTTP connections on recent Node versions.
+  const server = runtime.app.listen(0, '127.0.0.1'); servers.push(server)
+  await new Promise<void>(resolve => server.once('listening', resolve))
+  return server
+}
 afterEach(async () => {
-  for (const runtime of nodeRuntimes.splice(0)) await runtime.close()
+  for (const server of servers.splice(0)) await new Promise<void>((resolve, reject) => {
+    server.closeAllConnections(); server.close(error => error ? reject(error) : resolve())
+  })
   for (const runtime of runtimes.splice(0)) runtime.close()
   for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
 })
@@ -37,7 +47,8 @@ describe('15300 local authentication routes', () => {
       return Response.json({ profiles: [{ name: 'default', is_default: true, display_name: '丫头' }] })
     }) as typeof fetch }); runtimes.push(runtime)
     expect(runtime.auth.upstreamCredentials()).toBeUndefined()
-    const agent = request.agent(runtime.app.callback()), origin = 'http://127.0.0.1:15300'
+    const server = await listen(runtime)
+    const agent = request.agent(server), origin = 'http://127.0.0.1:15300'
     await agent.get('/api/realtime/capabilities').set('Host', '127.0.0.1:15300').expect(401)
     const boot = await agent.get('/api/app/bootstrap').set('Host', '127.0.0.1:15300').expect(200)
     expect(boot.body).toMatchObject({ authRequired: true, setupRequired: true })
@@ -61,7 +72,7 @@ describe('15300 local authentication routes', () => {
     await agent.put('/api/app/account/avatar').set('Host', '127.0.0.1:15300').set('Origin', origin)
       .set('X-CSRF-Token', ready.body.csrfToken).send({ avatar: 'https://example.test/avatar.png' }).expect(400)
     await agent.post('/api/realtime/channels').set('Host', '127.0.0.1:15300').set('Origin', origin).send({ channel: 'chat' }).expect(403)
-    await request(runtime.app.callback()).get('/api/realtime/capabilities').set('Host', '127.0.0.1:15300').expect(401)
+    await request(server).get('/api/realtime/capabilities').set('Host', '127.0.0.1:15300').expect(401)
   })
   it('creates an explicit administrator, then serves shared profiles through the service account', async () => {
     const home = mkdtempSync(join(tmpdir(), 'yaoyao-local-routes-'))
@@ -95,7 +106,8 @@ describe('15300 local authentication routes', () => {
     }) as typeof fetch
     const runtime = createApplication({ config, fetchImpl })
     runtimes.push(runtime)
-    const agent = request.agent(runtime.app.callback())
+    const server = await listen(runtime)
+    const agent = request.agent(server)
     const initial = await agent.get('/api/app/bootstrap').set('Host', '127.0.0.1:15300').expect(200)
     const setup = await agent.post('/api/app/setup')
       .set('Host', '127.0.0.1:15300').set('Origin', 'http://127.0.0.1:15300')
@@ -130,18 +142,18 @@ describe('15300 local authentication routes', () => {
       .set('X-CSRF-Token', ready.body.csrfToken).send({}).expect(201)
     const loginCode = new URL(accountPairing.body.qrPayload)
     expect(loginCode.hostname).toBe('login')
-    const scanned = request.agent(runtime.app.callback())
+    const scanned = request.agent(server)
     await scanned.post('/api/account-pair/v1/claim').set('Host', '127.0.0.1:15300')
       .send({ pairingId: loginCode.searchParams.get('id'), secret: loginCode.searchParams.get('secret') })
       .expect(201)
     const scannedIdentity = await scanned.get('/api/auth/me').set('Host', '127.0.0.1:15300').expect(200)
     expect(scannedIdentity.body).toMatchObject({ username: 'owner', role: 'admin' })
-    await request(runtime.app.callback()).post('/api/account-pair/v1/claim')
+    await request(server).post('/api/account-pair/v1/claim')
       .set('Host', '127.0.0.1:15300')
       .send({ pairingId: loginCode.searchParams.get('id'), secret: loginCode.searchParams.get('secret') })
       .expect(409)
 
-    const native = request.agent(runtime.app.callback())
+    const native = request.agent(server)
     await native.get('/api/status').set('Host', '127.0.0.1:15300').expect(200, /yaoyao-web/)
     await native.post('/auth/password-login').set('Host', '127.0.0.1:15300')
       .send({ provider: 'basic', username: 'owner', password: 'new-password', next: '' }).expect(200)

+ 6 - 1
tests/server/security.test.ts

@@ -134,7 +134,7 @@ describe('server security boundary', () => {
     expect(value.host).toBe('127.0.0.1')
     expect(value.insecureLan).toBe(false)
     expect(value).not.toHaveProperty('yaoyaoPluginSource')
-    expect(value.releaseSource).toBe('https://git.samien.cn/samien/hermes-yaoyao.git')
+    expect(value.releaseSource).toBe('https://github.com/Lsamien/hermes-yaoyao.git')
     expect(value.allowRemoteUpdate).toBe(false)
   })
 
@@ -145,5 +145,10 @@ describe('server security boundary', () => {
     expect(() => loadServerConfig(isolatedEnv({
       HERMES_YAOYAO_RELEASE_SOURCE: 'https://user:secret@git.example/hermes-yaoyao.git',
     }))).toThrow(/must not contain credentials/)
+    expect(() => loadServerConfig(isolatedEnv({
+      HERMES_YAOYAO_RELEASE_SOURCE: 'ssh://git:secret@git.example/hermes-yaoyao.git',
+    }))).toThrow(/must not contain credentials/)
+    expect(loadServerConfig(isolatedEnv({ HERMES_YAOYAO_RELEASE_SOURCE: 'ssh://git@git.example/hermes-yaoyao.git' })).releaseSource)
+      .toBe('ssh://git@git.example/hermes-yaoyao.git')
   })
 })

+ 12 - 0
tests/server/updateManager.test.ts

@@ -89,6 +89,18 @@ describe('system release contract', () => {
 })
 
 describe('SystemUpdateManager', () => {
+  it('uses the migrated source and compares versions rather than mirror commit identities', async () => {
+    const { root, config } = fixture()
+    let sourceSeen = ''
+    const manager = new SystemUpdateManager({ ...config, releaseSource: 'https://git.samien.cn/samien/hermes-yaoyao.git' }, {
+      projectRoot: root, platform: 'darwin', inspectRemote: async source => {
+        sourceSeen = source; return { manifest: current, commit: 'd'.repeat(40) }
+      },
+    })
+    expect(await manager.check()).toMatchObject({ updateAvailable: false, releaseSource: 'https://github.com/Lsamien/hermes-yaoyao.git' })
+    expect(sourceSeen).toBe('https://github.com/Lsamien/hermes-yaoyao.git')
+    await expect(manager.startUpdate(current.releaseVersion)).rejects.toThrow('最新版本')
+  })
   it('reports the current Web release and discovers a newer fixed release', async () => {
     const { manager } = fixture()
     const status = await manager.check()