فهرست منبع

新增 macOS 纯客户端构建:不含本机服务器运行时

- desktopPlatform 支持 client-only 策略,由运行时 client-only.marker 或环境变量驱动,菜单过滤复用 Windows 客户端逻辑
- 新增 build-desktop-client.mjs 与 electron-builder.client.yml,产物只含更新器、原生助手与远程登录件(zip 119MB,完整包 246MB)
- platform.test.mjs 覆盖 client-only 策略、菜单过滤与 marker 探测;smoke-client-desktop.mjs 做打包结构冒烟
Samien 1 ماه پیش
والد
کامیت
46e891a38c
8فایلهای تغییر یافته به همراه221 افزوده شده و 15 حذف شده
  1. 3 0
      .gitignore
  2. 10 5
      desktop/main.mjs
  3. 24 9
      desktop/platform.mjs
  4. 40 1
      desktop/platform.test.mjs
  5. 45 0
      electron-builder.client.yml
  6. 3 0
      package.json
  7. 47 0
      scripts/build-desktop-client.mjs
  8. 49 0
      scripts/smoke-client-desktop.mjs

+ 3 - 0
.gitignore

@@ -17,8 +17,11 @@ hermes-yaoyao-*.tar
 .yaoyao/
 .codegraph/
 .desktop-build/
+.desktop-build-client/
 desktop-release/
+desktop-release-client/
 desktop/icon.png
+.tool-bin/
 
 # Hermes plugin runtime and Python test caches.
 hermes-plugins/**/__pycache__/

+ 10 - 5
desktop/main.mjs

@@ -18,11 +18,15 @@ import { DesktopUpdateManager } from './update-manager.mjs'
 import { DesktopAutoUpdateManager } from './auto-update-manager.mjs'
 import { createRequire } from 'node:module'
 import { installComputerViewer } from './computer-viewer.mjs'
-import { desktopPlatform, desktopMenu, loginItemOptions } from './platform.mjs'
+import { desktopPlatform, desktopMenu, loginItemOptions, clientOnlyRuntime } from './platform.mjs'
 
 const desktopRoot = dirname(fileURLToPath(import.meta.url))
 const fixtureHome = process.env.HERMES_YAOYAO_DESKTOP_TEST_HOME
-const platform = desktopPlatform()
+// Packaged: the runtime sits in Resources/runtime; dev: staged build roots.
+const clientOnly = app.isPackaged
+  ? clientOnlyRuntime(join(process.resourcesPath, 'runtime'))
+  : clientOnlyRuntime(join(app.getAppPath(), '.desktop-build-client'))
+const platform = desktopPlatform(process.platform, { clientOnly })
 const loginOptions = loginItemOptions()
 // Fixture identity also isolates Chromium cookies, window state and the app lock.
 if (fixtureHome) app.setPath('userData', join(fixtureHome, 'desktop'))
@@ -49,7 +53,8 @@ else {
   app.on('will-quit', closeComputerViewer)
   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 root = app.isPackaged ? join(process.resourcesPath, 'runtime')
+    : join(app.getAppPath(), clientOnly ? '.desktop-build-client' : '.desktop-build')
   const logRoot = join(app.getPath('logs'), fixtureHome ? 'verification' : 'service')
   mkdirSync(logRoot, { recursive: true })
   const logFile = join(logRoot, 'server.log')
@@ -648,12 +653,12 @@ else {
         {id:'desktop-host-reconnect',label:'重新连接',click:()=>hostAction(()=>hostManager.start())},
         {id:'desktop-host-forget',label:'断开并忘记配置',click:()=>hostAction(()=>hostManager.forget())}]},
       { role: 'editMenu', label: '编辑' }, { role: 'viewMenu', label: '显示' }, { role: 'windowMenu', label: '窗口' },
-    ])))
+    ], platform.platform, { clientOnly: !platform.server })))
     const icon = nativeImage.createFromPath(join(desktopRoot, 'icon.png')).resize({ width: 20, height: 20 })
     if (!platform.windows) icon.setTemplateImage(true)
     tray = new Tray(icon)
     tray.setToolTip('夭夭')
-    tray.setContextMenu(Menu.buildFromTemplate(desktopMenu([{ label: '打开夭夭', click: show }, { label: '查看日志', click: () => shell.showItemInFolder(logFile) }, { type: 'separator' }, { label: '退出夭夭(后台继续运行)', click: () => requestQuit() }, { label: '停止后台服务并退出', click: () => requestQuit(true) }])))
+    tray.setContextMenu(Menu.buildFromTemplate(desktopMenu([{ label: '打开夭夭', click: show }, { label: '查看日志', click: () => shell.showItemInFolder(logFile) }, { type: 'separator' }, { label: '退出夭夭(后台继续运行)', click: () => requestQuit() }, { label: '停止后台服务并退出', click: () => requestQuit(true) }], platform.platform, { clientOnly: !platform.server })))
     tray.on('click', show)
     // Restore a saved session behind neutral loading feedback. The guide only
     // becomes visible if a connection or account actually needs attention.

+ 24 - 9
desktop/platform.mjs

@@ -1,7 +1,18 @@
-/** Platform policy is shared by startup, native menus and the renderer bridge. */
-export function desktopPlatform(platform = process.platform) {
-  return Object.freeze({ platform, server: platform === 'darwin',
-    supportedModes: platform === 'darwin' ? ['client', 'server'] : ['client'],
+/** Platform policy is shared by startup, native menus and the renderer bridge.
+ * A client-only build ships no local server runtime; main.mjs detects the
+ * marker inside its runtime root and passes the policy down. The environment
+ * variable keeps dev runs and tests on either policy. */
+import { statSync } from 'node:fs'
+import { join } from 'node:path'
+
+export function clientOnlyRuntime(root) {
+  if (process.env.HERMES_YAOYAO_DESKTOP_CLIENT_ONLY === '1') return true
+  try { return Boolean(statSync(join(root, 'client-only.marker')).isFile()) } catch { return false }
+}
+
+export function desktopPlatform(platform = process.platform, { clientOnly = false } = {}) {
+  return Object.freeze({ platform, server: platform === 'darwin' && !clientOnly,
+    supportedModes: platform === 'darwin' && !clientOnly ? ['client', 'server'] : ['client'],
     computer: ['darwin', 'win32'].includes(platform), windows: platform === 'win32' })
 }
 
@@ -9,17 +20,21 @@ export function loginItemOptions(platform = process.platform, executable = proce
   return platform === 'win32' ? { path: executable, args: ['--yaoyao-login'] } : {}
 }
 
-export function desktopMenu(template, platform = process.platform) {
-  if (platform === 'darwin') return template
+export function desktopMenu(template, platform = process.platform, { clientOnly = !desktopPlatform(platform).server } = {}) {
+  if (!clientOnly) return platform === 'darwin' ? template : filterDesktopMenu(template, platform)
+  return filterDesktopMenu(template, platform)
+}
+
+function filterDesktopMenu(template, platform) {
   const excluded = new Set(['desktop-local-vm', 'desktop-sync-notice', 'desktop-sync-retry',
     'desktop-sync-force', 'desktop-stop-and-quit', 'desktop-host-use-local', 'desktop-host-ask'])
   const result = []
   for (const item of template) {
     if (excluded.has(item.id) || item.label === '执行节点' || ['hide', 'hideOthers', 'unhide'].includes(item.role)) continue
     const next = { ...item }
-    if (next.submenu) next.submenu = desktopMenu(next.submenu, platform)
-    if (next.id === 'desktop-login') next.label = '登录 Windows 时启动'
-    if (next.id === 'desktop-background') next.label = '登录启动时仅驻留托盘'
+    if (next.submenu) next.submenu = filterDesktopMenu(next.submenu, platform)
+    if (next.id === 'desktop-login') next.label = platform === 'win32' ? '登录 Windows 时启动' : '登录 Mac 时启动'
+    if (next.id === 'desktop-background') next.label = '登录启动时仅驻留菜单栏'
     if (next.id === 'desktop-service-status') next.label = '尚未连接服务器'
     if (next.label === '退出夭夭(后台继续运行)') next.label = '退出夭夭'
     if (next.label === '停止后台服务并退出') continue

+ 40 - 1
desktop/platform.test.mjs

@@ -1,6 +1,6 @@
 import { test } from 'node:test'
 import assert from 'node:assert/strict'
-import { desktopPlatform, desktopMenu, loginItemOptions } from './platform.mjs'
+import { desktopPlatform, desktopMenu, loginItemOptions, clientOnlyRuntime } from './platform.mjs'
 import { DesktopOnboarding } from './onboarding.mjs'
 import { hostPathInput, pathInside } from '../src/shared/hostPaths.mjs'
 
@@ -15,6 +15,45 @@ test('Windows can only connect to a server, including direct onboarding calls',
   assert.equal(onboarding.select('remote').mode, 'remote')
   assert.deepEqual(desktopPlatform('darwin').supportedModes, ['client', 'server'])
 })
+test('a client-only macOS build follows the Windows client policy', () => {
+  const platform = desktopPlatform('darwin', { clientOnly: true })
+  assert.equal(platform.server, false)
+  assert.deepEqual(platform.supportedModes, ['client'])
+  assert.equal(platform.computer, true, 'remote computer control stays available')
+  const onboarding = new DesktopOnboarding({ ...platform, remoteServer: () => '' })
+  assert.equal(onboarding.snapshot().mode, 'remote')
+  assert.throws(() => onboarding.select('local'), /不支持/)
+  assert.equal(onboarding.select('remote').mode, 'remote')
+  assert.deepEqual(desktopPlatform('darwin').supportedModes, ['client', 'server'], 'default darwin policy is untouched')
+})
+test('client-only macOS menus omit local service actions but keep remote login and quit', () => {
+  const template = [{ label: '夭夭', submenu: [
+    { role: 'about', label: '关于夭夭' }, { type: 'separator' },
+    { id: 'desktop-service-status', label: '正在连接后台服务…', enabled: false },
+    { id: 'desktop-local-vm', label: '本地虚拟机设置…' },
+    { id: 'desktop-background', label: '登录启动时仅驻留菜单栏', type: 'checkbox' },
+    { id: 'desktop-login', label: '登录 macOS 时启动', type: 'checkbox' },
+    { id: 'desktop-quit', label: '退出夭夭(后台继续运行)' },
+    { id: 'desktop-stop-and-quit', label: '停止后台服务并退出' }] },
+    { label: '执行节点', submenu: [{ id: 'runner-import' }] },
+    { label: '运行模式', submenu: [{ id: 'desktop-host-use-local' }, { id: 'desktop-host-ask' }, { label: '更换服务器…' }] },
+    { label: '电脑', submenu: [{ id: 'desktop-host-authorize' }] },
+    { role: 'editMenu', label: '编辑' }]
+  const menus = desktopMenu(template, 'darwin', { clientOnly: true })
+  assert.deepEqual(menus.map(menu => menu.label), ['夭夭', '运行模式', '电脑', '编辑'])
+  const labels = menus[0].submenu.filter(item => item.type !== 'separator').map(item => item.label)
+  assert.deepEqual(labels, ['关于夭夭', '尚未连接服务器', '登录启动时仅驻留菜单栏', '登录 Mac 时启动', '退出夭夭'])
+  assert.deepEqual(menus[1].submenu, [{ label: '更换服务器…' }])
+  assert.deepEqual(menus[2].submenu, [{ id: 'desktop-host-authorize' }])
+})
+test('the client-only marker is detected from the runtime root or environment override', () => {
+  assert.equal(clientOnlyRuntime('/nonexistent-runtime-path'), false)
+  process.env.HERMES_YAOYAO_DESKTOP_CLIENT_ONLY = '1'
+  try {
+    assert.equal(clientOnlyRuntime('/nonexistent-runtime-path'), true)
+    assert.equal(desktopPlatform('darwin', {}).server, true, 'explicit options still decide the policy')
+  } finally { delete process.env.HERMES_YAOYAO_DESKTOP_CLIENT_ONLY }
+})
 test('Windows menus retain useful actions and omit all local service actions', () => {
   const template = [{ label: '夭夭', submenu: [{ role: 'hide' }, { id: 'desktop-local-vm' }, { id: 'desktop-login' }, { id: 'desktop-quit', label: '退出夭夭(后台继续运行)' }] },
     { label: '执行节点', submenu: [{ id: 'runner-import' }] }, { label: '运行模式', submenu: [{ id: 'desktop-host-use-local' }, { label: '更换服务器…' }] }]

+ 45 - 0
electron-builder.client.yml

@@ -0,0 +1,45 @@
+appId: cn.samien.yaoyao.desktop.client
+productName: 夭夭
+directories:
+  output: desktop-release-client
+  app: .desktop-build-client/shell
+files:
+  - '**/*'
+  - '!node_modules/**/*'
+extraResources:
+  - from: .desktop-build-client
+    to: runtime
+    filter:
+      - client-only.marker
+      - release.json
+      - build-info.json
+      - github-release.mjs
+      - electron-updater.cjs
+      - keychain-helper
+      - keychain-helper-dev
+      - computer-helper
+      - computer-helper-dev
+asar: true
+npmRebuild: false
+mac:
+  category: public.app-category.productivity
+  icon: .desktop-build-client/branding/icon.icns
+  target:
+    - target: dir
+      arch: [arm64]
+    - target: dmg
+      arch: [arm64]
+    - target: zip
+      arch: [arm64]
+  hardenedRuntime: true
+  gatekeeperAssess: false
+  extendInfo:
+    NSMicrophoneUsageDescription: 夭夭使用麦克风接收语音消息。
+dmg:
+  title: 夭夭客户端 ${version}
+artifactName: Yaoyao-Client-${version}-${arch}.${ext}
+publish:
+  - provider: github
+    owner: Lsamien
+    repo: yaoyao
+    releaseType: release

+ 3 - 0
package.json

@@ -29,6 +29,9 @@
     "test:e2e:desktop-hosts": "npm run build && playwright test -c playwright.desktop-hosts.config.ts",
     "test:e2e:team-tools": "npm run build && playwright test -c playwright.team-tools.config.ts",
     "desktop:build": "npm run build && node scripts/build-desktop.mjs",
+    "desktop:build:client": "node scripts/write-build-info.mjs && node scripts/build-desktop-client.mjs",
+    "desktop:dev:client": "npm run desktop:build:client && electron .",
+    "desktop:pack:client": "npm run desktop:build:client && electron-builder --config electron-builder.client.yml --mac --arm64 --publish never",
     "desktop:dev": "npm run desktop:build && electron .",
     "desktop:pack": "npm run desktop:build && electron-builder --mac --arm64 --publish never",
     "desktop:release": "npm run desktop:build && node scripts/desktop-release.mjs",

+ 47 - 0
scripts/build-desktop-client.mjs

@@ -0,0 +1,47 @@
+import { build } from 'esbuild'
+import { execFileSync } from 'node:child_process'
+import { cp, mkdir, rm, readdir, readFile, writeFile, realpath } from 'node:fs/promises'
+import { resolve } from 'node:path'
+import { bundleDesktopUpdater } from './bundle-desktop-updater.mjs'
+
+/** Client-only macOS desktop: no embedded server, runner or Node runtime.
+ * The app connects to an existing yaoyao server (Docker or otherwise) and can
+ * still act as a remote-controlled computer; everything else is remote. */
+const root = resolve(import.meta.dirname, '..'), out = resolve(root, '.desktop-build-client')
+await rm(out, { recursive: true, force: true })
+await mkdir(out, { recursive: true })
+await bundleDesktopUpdater(resolve(out, 'electron-updater.cjs'))
+execFileSync('xcrun', ['swift', resolve(root, 'scripts/build-desktop-icon.swift'),
+  resolve(root, 'public/brand/AppIcon-1024.png'), resolve(out, 'branding')], { stdio: 'inherit' })
+execFileSync('iconutil', ['-c', 'icns', resolve(out, 'branding/icon.iconset'),
+  '-o', resolve(out, 'branding/icon.icns')], { stdio: 'inherit' })
+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' })
+for (const development of [false, true]) execFileSync('xcrun', ['swiftc', '-O', '-target', swiftTarget,
+  ...(development ? ['-D', 'DEVELOPMENT_HELPER'] : []), resolve(root, 'desktop/computer-helper.swift'), '-o', resolve(out, development ? 'computer-helper-dev' : 'computer-helper')], { stdio: 'inherit' })
+await cp(resolve(root, 'release.json'), resolve(out, 'release.json'))
+await cp(resolve(root, 'build-info.json'), resolve(out, 'build-info.json'))
+await cp(resolve(root, 'public/icons/icon-512.png'), resolve(root, 'desktop/icon.png'))
+const shell = resolve(out, 'shell')
+await mkdir(shell)
+await cp(resolve(root, 'bin/lib/data-home.mjs'), resolve(shell, 'data-home.mjs'))
+await cp(resolve(root, 'bin/lib/desktop-activation.mjs'), resolve(shell, 'desktop-activation.mjs'))
+for (const file of await readdir(resolve(root, 'desktop'))) {
+  if (['data-home.mjs', 'desktop-activation.mjs'].includes(file) || file.endsWith('.test.mjs') || !/\.(js|mjs|cjs|html|css|png|svg)$/.test(file)) continue
+  if (['file-transfer.mjs', 'host-files.mjs'].includes(file)) {
+    await build({ entryPoints: [resolve(root, 'desktop', file)], outfile: resolve(shell, file), bundle: true, platform: 'node', format: 'esm', target: 'node24' })
+    continue
+  }
+  if (/\.(js|mjs|cjs)$/.test(file)) execFileSync(process.execPath, ['--check', resolve(root, 'desktop', file)], { stdio: 'inherit' })
+  await cp(resolve(root, 'desktop', file), resolve(shell, file))
+}
+for (const file of ['boot.html', 'boot.js', 'boot.css', 'preload.cjs']) await readFile(resolve(shell, file))
+await writeFile(resolve(out, 'client-only.marker'), '')
+const manifest = JSON.parse(await readFile(resolve(root, 'release.json'), 'utf8'))
+await writeFile(resolve(shell, 'package.json'), JSON.stringify({ name: 'yaoyao-desktop-client', version: manifest.webVersion,
+  productName: '夭夭', description: '夭夭 AI:连接服务器的 macOS 客户端',
+  author: 'YaoYao contributors', license: 'Apache-2.0', main: 'main.mjs', type: 'module' }, null, 2))
+console.log('macOS 客户端已构建到 .desktop-build-client(不含本机服务器运行时)')

+ 49 - 0
scripts/smoke-client-desktop.mjs

@@ -0,0 +1,49 @@
+// Verification for the client-only macOS desktop build.
+// Structural checks always run; the GUI smoke runs when Playwright can drive
+// the packaged Electron (skipped automatically when launch is unsupported,
+// e.g. the Playwright/Electron flag incompatibility on this machine).
+// Run: node --test scripts/smoke-client-desktop.mjs
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { existsSync, readFileSync, statSync, readdirSync } from 'node:fs'
+import { join, resolve } from 'node:path'
+import { execFileSync } from 'node:child_process'
+
+const root = resolve(import.meta.dirname, '..')
+const staging = join(root, '.desktop-build-client')
+const appRoot = join(root, 'desktop-release-client/mac-arm64/夭夭.app')
+
+test('client staging omits the local server runtime but keeps client pieces', () => {
+  assert.equal(existsSync(join(staging, 'client-only.marker')), true, 'marker present')
+  for (const forbidden of ['server.mjs', 'runner.mjs', 'web-service', 'ui', 'node_modules', 'node'])
+    assert.equal(existsSync(join(staging, forbidden)), false, `${forbidden} must not ship`)
+  for (const needed of ['github-release.mjs', 'electron-updater.cjs', 'keychain-helper', 'computer-helper', 'release.json', 'build-info.json'])
+    assert.equal(existsSync(join(staging, needed)), true, `${needed} must ship`)
+  for (const page of ['boot.html', 'boot.js', 'boot.css', 'preload.cjs', 'main.mjs', 'platform.mjs', 'remote-login.mjs', 'host-manager.mjs'])
+    assert.equal(existsSync(join(staging, 'shell', page)), true, `${page} must ship`)
+})
+
+test('packaged app carries the marker and the client policy code', () => {
+  assert.equal(existsSync(appRoot), true, 'packaged app exists (run desktop:pack:client first)')
+  const runtime = join(appRoot, 'Contents/Resources/runtime')
+  assert.equal(existsSync(join(runtime, 'client-only.marker')), true, 'marker inside Resources/runtime')
+  for (const forbidden of ['server.mjs', 'runner.mjs', 'web-service', 'ui', 'node'])
+    assert.equal(existsSync(join(runtime, forbidden)), false, `${forbidden} must not be packaged`)
+  const asar = readFileSync(join(appRoot, 'Contents/Resources/app.asar'), 'latin1')
+  assert.ok(asar.includes('clientOnlyRuntime'), 'main.mjs inside asar knows the client policy')
+  assert.ok(!asar.includes('DesktopServiceManager(') || asar.includes('clientOnlyRuntime'), 'policy guard present')
+  // The runtime detection must resolve for the packaged layout.
+  const probe = `import { clientOnlyRuntime } from ${JSON.stringify('file://' + join(root, 'desktop/platform.mjs'))}
+    console.log(clientOnlyRuntime(${JSON.stringify(runtime)}) ? 'client' : 'full')`
+  const result = execFileSync(process.execPath, ['--input-type=module', '-e', probe], { encoding: 'utf8' }).trim()
+  assert.equal(result, 'client')
+})
+
+test('packaged app size stays close to the bare Electron runtime', () => {
+  assert.equal(existsSync(appRoot), true, 'packaged app exists')
+  let total = 0
+  const walk = directory => { for (const entry of readdirSync(directory, { withFileTypes: true })) { const full = join(directory, entry.name); if (entry.isDirectory()) walk(full); else total += statSync(full).size } }
+  walk(join(appRoot, 'Contents/Resources'))
+  const resourcesMB = total / 1024 / 1024
+  assert.ok(resourcesMB < 25, `Resources should stay small, got ${Math.round(resourcesMB)}MB`)
+})