Преглед на файлове

feat(bot): 在 Bot 设置里给每台电脑改名

本机和已配对的脚本机可以改显示名。机器人按新名字点名,写「本机」仍然指向夭夭所在的电脑。
Samien преди 1 месец
родител
ревизия
027d4dc

+ 31 - 2
src/client/components/app/HostToolsPanel.vue

@@ -9,6 +9,29 @@ const serverComputer = ref(true)
 const vm = ref(true)
 const cloud = ref(true)
 const loading = ref(false), saving = ref(false), error = ref(''), saved = ref(false)
+const hosts = ref<{id:string; label:string; name:string}[]>([])
+const drafts = ref<Record<string, string>>({})
+const nameError = ref('')
+async function loadNames() {
+  nameError.value = ''
+  try {
+    const value = await apiRequest<{localName?: string; hosts: {id:string; name:string; displayName?: string; enabled:boolean; hostName?: string}[]}>('/api/app/admin/desktop-hosts')
+    const rows = [{ id: 'local', label: '本机 · 夭夭所在的电脑', name: value.localName || '' }]
+    for (const host of value.hosts) {
+      if (!host.enabled) continue
+      rows.push({ id: host.id, label: host.hostName || host.name, name: host.displayName || '' })
+    }
+    hosts.value = rows
+    drafts.value = Object.fromEntries(rows.map(row => [row.id, row.name]))
+  } catch (cause) { nameError.value = cause instanceof Error ? cause.message : '无法读取电脑名单' }
+}
+async function saveName(id: string) {
+  nameError.value = ''
+  try {
+    await apiRequest(`/api/app/admin/desktop-hosts/${encodeURIComponent(id)}/name`, { method: 'PUT', body: { name: drafts.value[id] || '' } })
+    await loadNames()
+  } catch (cause) { nameError.value = cause instanceof Error ? cause.message : '改名失败' }
+}
 
 type Settings = { denyServerTools: boolean; scriptMachine: boolean; serverComputer: boolean; vm: boolean; cloud: boolean }
 function apply(value: Settings) {
@@ -38,7 +61,7 @@ async function save() {
   } catch (cause) { error.value = cause instanceof Error ? cause.message : '保存失败,请重试' }
   finally { saving.value = false }
 }
-onMounted(load)
+onMounted(() => { void load(); void loadNames() })
 </script>
 
 <template>
@@ -54,9 +77,15 @@ onMounted(load)
       <p v-if="saved" role="status">已保存。下一次开始或恢复的会话会按这个设置执行。</p>
       <div class="actions"><button type="button" :disabled="loading || saving" @click="load">重新读取</button><button type="submit" class="save" :disabled="loading || saving">{{ saving ? '保存中…' : loading ? '读取中…' : '保存设置' }}</button></div>
     </form>
+    <div class="names">
+      <p>每台电脑可以改名。机器人按这个名字点名。本机留空时仍叫「本机」,而且写「本机」一定是夭夭所在的电脑。</p>
+      <p v-if="nameError" class="error" role="alert">{{ nameError }}</p>
+      <label v-for="host in hosts" :key="host.id"><span>{{ host.label }}</span><input v-model="drafts[host.id]" maxlength="64" :placeholder="host.id === 'local' ? '本机' : host.label" :aria-label="`名字 ${host.label}`" /><button type="button" @click="saveName(host.id)">保存名字</button></label>
+      <p v-if="!hosts.length && !nameError">还没有可改名的电脑。</p>
+    </div>
   </section>
 </template>
 
 <style scoped>
-.host-tools{display:grid;gap:18px;max-width:680px;color:var(--text-primary);font-size:13px;line-height:1.7}.host-tools p{margin:0}.host-tools small{display:block;color:var(--text-secondary);font-size:12px}.host-tools form{display:grid;gap:14px}.toggle{display:flex;gap:12px;align-items:flex-start;cursor:pointer}.toggle input{width:20px;height:20px;margin-top:2px;accent-color:var(--accent)}.actions{display:flex;gap:10px;justify-content:flex-end}.actions button{min-height:44px;padding:8px 16px;border:1px solid var(--line);border-radius:9px;background:var(--surface);color:var(--text-primary);font:inherit;cursor:pointer}.actions .save{background:var(--accent);color:var(--text-on-solid)}button:disabled{opacity:.55;cursor:default}.error{color:var(--danger)}input:focus-visible,button:focus-visible{outline:2px solid var(--accent);outline-offset:3px}
+.host-tools{display:grid;gap:18px;max-width:680px;color:var(--text-primary);font-size:13px;line-height:1.7}.host-tools p{margin:0}.host-tools small{display:block;color:var(--text-secondary);font-size:12px}.host-tools form{display:grid;gap:14px}.toggle{display:flex;gap:12px;align-items:flex-start;cursor:pointer}.toggle input{width:20px;height:20px;margin-top:2px;accent-color:var(--accent)}.actions{display:flex;gap:10px;justify-content:flex-end}.actions button{min-height:44px;padding:8px 16px;border:1px solid var(--line);border-radius:9px;background:var(--surface);color:var(--text-primary);font:inherit;cursor:pointer}.actions .save{background:var(--accent);color:var(--text-on-solid)}button:disabled{opacity:.55;cursor:default}.names{display:grid;gap:10px}.names label{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.2fr) auto;gap:8px;align-items:center}.names input{min-height:44px;padding:8px 10px;border:1px solid var(--line);border-radius:9px;background:var(--surface);color:var(--text-primary);font:inherit}.names button{min-height:44px;padding:8px 12px;border:1px solid var(--line);border-radius:9px;background:var(--surface);color:var(--text-primary);font:inherit;cursor:pointer}.error{color:var(--danger)}input:focus-visible,button:focus-visible{outline:2px solid var(--accent);outline-offset:3px}
 </style>

+ 35 - 0
src/server/computerNames.ts

@@ -0,0 +1,35 @@
+import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { randomUUID } from 'node:crypto'
+import { HttpError } from './errors.js'
+
+const pathOf = (home: string) => join(home, 'computer-names.json')
+const idOk = (id: string) => id === 'local' || /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(id)
+
+export function readComputerNames(home: string): Record<string, string> {
+  const path = pathOf(home)
+  if (!existsSync(path)) return {}
+  try {
+    const value = JSON.parse(readFileSync(path, 'utf8')) as { names?: Record<string, unknown> }
+    const names: Record<string, string> = {}
+    for (const [id, name] of Object.entries(value?.names ?? {})) {
+      if (idOk(id) && typeof name === 'string' && name.trim()) names[id] = name.trim().slice(0, 64)
+    }
+    return names
+  } catch { return {} }
+}
+
+export function saveComputerName(home: string, id: string, name: string): Record<string, string> {
+  if (!idOk(id)) throw new HttpError(400, '这台电脑不能改名', 'computer_unknown')
+  const trimmed = name.trim()
+  if (trimmed.length > 64) throw new HttpError(400, '名字最长 64 个字', 'computer_name_invalid')
+  const names = readComputerNames(home)
+  if (trimmed) names[id] = trimmed
+  else delete names[id]
+  mkdirSync(home, { recursive: true, mode: 0o700 })
+  const path = pathOf(home)
+  const temporary = path + '.' + randomUUID()
+  writeFileSync(temporary, JSON.stringify({ names }, null, 2) + '\n', { mode: 0o600 })
+  renameSync(temporary, path)
+  return names
+}

+ 7 - 4
src/server/desktopEnvironments.ts

@@ -8,6 +8,7 @@ import type {LocalAuthStore} from './localAuth.js'
 import type {WorkspaceNodes,GatewayTarget} from './workspaceGateway.js'
 import {allowsHostEnvironment,type WorkspaceAgent} from '../shared/workspace.js'
 import {readHostTools} from './hostToolSettings.js'
+import {readComputerNames} from './computerNames.js'
 import type {ComputerFrame,ComputerControlStatus} from '../shared/computerControl.js'
 import {requireTeamToolBridge} from './workspaceToolLease.js'
 
@@ -84,6 +85,8 @@ export class DesktopEnvironments {
  /** 本机 is the computer running YaoYao. A non-local binding still wins for the
  *  default session. Other script machines are selected by name, never by being the only one online. */
  private computers(){try{const s=readHostTools(this.store.home);return {script:s.scriptMachine,server:s.serverComputer}}catch{return {script:false,server:false}}}
+ private names(){try{return readComputerNames(this.store.home)}catch{return {}}}
+ private label(s:HostState){const custom=this.names()[s.key];if(s.key==='local')return custom||'本机';return custom||s.info?.name||''}
  private hostOpen(s:HostState){const g=this.computers();return s.key==='local'?g.server:g.script}
  private sessionFor(agent:WorkspaceAgent):HostState|undefined{
   const g=this.computers()
@@ -103,7 +106,7 @@ export class DesktopEnvironments {
    throw new HttpError(409,'夭夭所在的电脑未连接,请在这台电脑上打开夭夭。','desktop_offline')
   }
   if(!g.script)throw new HttpError(409,'全局设置未开放脚本机操作。','desktop_offline')
-  for(const s of this.sessions.values())if(s.key===host||(s.info&&s.info.name.toLowerCase()===host.toLowerCase()))return s
+  const names=this.names();for(const s of this.sessions.values())if(s.key===host||(s.info&&s.info.name.toLowerCase()===host.toLowerCase())||(names[s.key]&&names[s.key].toLowerCase()===host.toLowerCase()))return s
   throw new HttpError(409,`指定的电脑不在线:${host.slice(0,64)}`,'desktop_host_unknown')
  }
  selected(owner:string,agent:WorkspaceAgent):Mode|undefined {if(agent.archived||agent.remoteAgentId||agent.temporaryGoalId)return undefined;const g=this.computers();if(!g.script&&!g.server)return undefined;if(agent.envs)return 'local';if(agent.computer==='local')return 'local';if(agent.computer==='auto'&&agent.execution!=='computer'){const s=this.sessionFor(agent);if(s&&this.ready(s,owner))return 'local'}return undefined}
@@ -130,11 +133,11 @@ export class DesktopEnvironments {
   if(!s)throw new HttpError(409,'桌面端未连接,请在电脑上打开夭夭。','desktop_offline')
   return s
  }
- onlineHostsLine(owner:string){const names=[...this.sessions.values()].filter(s=>this.hostOnline(s)&&this.hostOpen(s)).map(s=>s.key==='local'?'本机':(s.info?.name??s.key));return names.length?`当前在线电脑:${names.join('、')}。desktop_* 工具可用 host 参数(名称或编号)选择目标电脑;省略 host 或写「本机」时使用夭夭所在的这台电脑。`:''}
+ onlineHostsLine(owner:string){const names=[...this.sessions.values()].filter(s=>this.hostOnline(s)&&this.hostOpen(s)).map(s=>s.key==='local'?(this.names()[s.key]?this.names()[s.key]+'(本机)':'本机'):(this.label(s)||s.key));return names.length?`当前在线电脑:${names.join('、')}。desktop_* 工具可用 host 参数(名称或编号)选择目标电脑;省略 host 或写「本机」时使用夭夭所在的这台电脑。`:''}
  private agent(owner:string,id:string){const a=this.store.require<WorkspaceAgent>(owner,'agent',id);this.nodes.requireSource(owner,a);if(a.archived||a.remoteAgentId||a.temporaryGoalId)throw new HttpError(409,'此机器人无法使用桌面环境','computer_unavailable');return a}
  private resource(owner:string,agent:WorkspaceAgent,mode:Mode){return mode==='local'?'local':digest(this.ownerKey(owner)+':'+agent.id+':'+(agent.browserProfile??'persistent'))}
- hostStates(owner:string){return [...this.sessions.values()].map(s=>{const online=this.hostOnline(s),info=online?s.info!:undefined;return {id:s.key,name:info?.name??'',platform:info?.platform??'',online,local:{supported:info?.platform==='darwin',authorized:!!info&&info.approved.includes(this.ownerKey(owner)),screen:info?.screen===true,accessibility:info?.accessibility===true,ready:!!info&&this.ready(s,owner),fullAuthorized:!!info&&(info.full??[]).includes(this.ownerKey(owner))},browser:{available:online}}}).sort((a,b)=>a.id==='local'?-1:b.id==='local'?1:a.name.localeCompare(b.name))}
- state(owner:string,agent:WorkspaceAgent){const mode=this.selected(owner,agent),s=this.sessionFor(agent),online=!!s&&this.hostOnline(s),info=online?s!.info:undefined;return {online,host:info?{id:s!.key,name:info.name,platform:info.platform}:null,local:{supported:info?.platform==='darwin',authorized:!!info&&info.approved.includes(this.ownerKey(owner)),screen:info?.screen===true,accessibility:info?.accessibility===true,ready:!!info&&this.ready(s!,owner),fullAuthorized:!!info&&(info.full??[]).includes(this.ownerKey(owner))},browser:{available:online,profile:agent.browserProfile??'persistent'},selected:mode??null,agent:this.store.agentSummary(agent),hosts:this.hostStates(owner)}}
+ hostStates(owner:string){return [...this.sessions.values()].map(s=>{const online=this.hostOnline(s),info=online?s.info!:undefined;return {id:s.key,name:this.label(s),platform:info?.platform??'',online,local:{supported:info?.platform==='darwin',authorized:!!info&&info.approved.includes(this.ownerKey(owner)),screen:info?.screen===true,accessibility:info?.accessibility===true,ready:!!info&&this.ready(s,owner),fullAuthorized:!!info&&(info.full??[]).includes(this.ownerKey(owner))},browser:{available:online}}}).sort((a,b)=>a.id==='local'?-1:b.id==='local'?1:a.name.localeCompare(b.name))}
+ state(owner:string,agent:WorkspaceAgent){const mode=this.selected(owner,agent),s=this.sessionFor(agent),online=!!s&&this.hostOnline(s),info=online?s!.info:undefined;return {online,host:info?{id:s!.key,name:this.label(s!),platform:info.platform}:null,local:{supported:info?.platform==='darwin',authorized:!!info&&info.approved.includes(this.ownerKey(owner)),screen:info?.screen===true,accessibility:info?.accessibility===true,ready:!!info&&this.ready(s!,owner),fullAuthorized:!!info&&(info.full??[]).includes(this.ownerKey(owner))},browser:{available:online,profile:agent.browserProfile??'persistent'},selected:mode??null,agent:this.store.agentSummary(agent),hosts:this.hostStates(owner)}}
  /** Parallel envs allow each requested leg; legacy agents still use the single toolMode. */
  private modeOpen(owner:string,agent:WorkspaceAgent,mode:Mode){if(agent.envs){const wanted=this.modes(owner,agent);return mode==='browser'?wanted.browser:wanted.desktop}return this.toolMode(owner,agent)===mode}
  private check(s:HostState|undefined,owner:string,agentId:string,mode:Mode,epoch:string,version:number){const a=this.agent(owner,agentId);if(!s||!this.hostOnline(s)||s.info!.id!==epoch)throw new HttpError(409,'桌面端已断开,请在电脑上打开夭夭后重试。','desktop_offline');if(this.auth.pushAuthorizationVersion(owner)!==version||!this.modeOpen(owner,a,mode))throw new HttpError(410,'电脑授权已改变,请重新连接。','computer_control_expired');if(mode==='local'&&!this.ready(s,owner))throw new HttpError(403,'请在桌面端授权本机控制,并开启屏幕录制和辅助功能。','desktop_permission_required')}

+ 5 - 2
src/server/desktopHosts.ts

@@ -6,6 +6,7 @@ import { HttpError } from './errors.js'
 import { parse, type WorkspaceStore } from './workspaceStore.js'
 import type { LocalAuthStore } from './localAuth.js'
 import { DESKTOP_HOST_PROTOCOL, type DesktopHostExchange, type DesktopHostRecord, type DesktopHostSummary } from '../shared/desktopHost.js'
+import { readComputerNames, saveComputerName } from './computerNames.js'
 
 const hash = (value: string) => createHash('sha256').update(value).digest('hex')
 const enrollInput = z.object({ name: z.string().trim().min(1).max(100) }).strict()
@@ -35,7 +36,8 @@ export class DesktopHostHub {
   summary(record: DesktopHostRecord): DesktopHostSummary {
     const { tokenHash: _secret, ...publicRecord } = record
     const state = this.online.get(record.id)
-    return { ...publicRecord, online: !!state && Date.now() - state.seen < 15_000, lastSeen: state?.seen, hostName: state?.name, platform: state?.platform }
+    const displayName = readComputerNames(this.store.home)[record.id] || ''
+    return { ...publicRecord, online: !!state && Date.now() - state.seen < 15_000, lastSeen: state?.seen, hostName: state?.name, platform: state?.platform, displayName }
   }
   remove(id: string) {
     const record = this.store.require<DesktopHostRecord>('_system', 'desktop-host', id)
@@ -46,7 +48,8 @@ export class DesktopHostHub {
   }
   adminRouter(): Router {
     const router = new Router()
-    router.get('/api/app/admin/desktop-hosts', ctx => { this.auth.requireAdmin(ctx); ctx.set('Cache-Control', 'no-store'); ctx.body = { protocol: DESKTOP_HOST_PROTOCOL, hosts: this.records().map(record => this.summary(record)) } })
+    router.get('/api/app/admin/desktop-hosts', ctx => { this.auth.requireAdmin(ctx); ctx.set('Cache-Control', 'no-store'); const names = readComputerNames(this.store.home); ctx.body = { protocol: DESKTOP_HOST_PROTOCOL, localName: names.local || '', hosts: this.records().map(record => this.summary(record)) } })
+    router.put('/api/app/admin/desktop-hosts/:id/name', ctx => { this.auth.requireAdmin(ctx); const id = ctx.params.id; if (id !== 'local' && !this.records().some(record => record.id === id && record.enabled)) throw new HttpError(404, '这台电脑不存在', 'desktop_host_unknown'); const name = typeof (ctx.request as any).body?.name === 'string' ? (ctx.request as any).body.name : ''; ctx.body = { id, name: saveComputerName(this.store.home, id, name)[id] || '' } })
     router.post('/api/app/admin/desktop-hosts', ctx => { this.auth.requireAdmin(ctx); ctx.body = this.enroll((ctx.request as any).body); ctx.status = 201 })
     router.delete('/api/app/admin/desktop-hosts/:id', ctx => { this.auth.requireAdmin(ctx); this.remove(ctx.params.id); ctx.body = { ok: true } })
     return router

+ 1 - 0
src/shared/desktopHost.ts

@@ -11,6 +11,7 @@ export interface DesktopHostSummary extends Omit<DesktopHostRecord, 'tokenHash'>
   lastSeen?: number
   hostName?: string
   platform?: string
+  displayName?: string
 }
 /** Downloaded once as desktop-host.json and imported by the remote Mac App. */
 export interface DesktopHostConfiguration {

+ 11 - 0
tests/server/desktopHosts.test.ts

@@ -57,6 +57,17 @@ it('revocation fences the transport and notifies the live session',async()=>{
   expect(dropped).toEqual([hostId])
   await post(`/api/desktop-host/v1/${hostId}/exchange`).send(body()).expect(403)
 })
+it('renames the local computer and a paired host without touching the token', async () => {
+  await enroll()
+  const local = await request(app()).put('/api/app/admin/desktop-hosts/local/name').send({ name: '工作室' }).expect(200)
+  expect(local.body).toEqual({ id: 'local', name: '工作室' })
+  const renamed = await request(app()).put(`/api/app/admin/desktop-hosts/${hostId}/name`).send({ name: '前台' }).expect(200)
+  expect(renamed.body.name).toBe('前台')
+  const list = (await request(app()).get('/api/app/admin/desktop-hosts').expect(200)).body
+  expect(list.localName).toBe('工作室')
+  expect(list.hosts[0].displayName).toBe('前台')
+  expect(JSON.stringify(list)).not.toContain(token)
+})
 it('validates the exchange payload shape',async()=>{
   await enroll()
   await post(`/api/desktop-host/v1/${hostId}/exchange`).send({host:{id:'not-a-uuid',name:'x',platform:'darwin',screen:true,accessibility:true,approved:[]},results:[]}).expect(400)