Jelajahi Sumber

验证原生输入与独立浏览器并在显示变化时作废截图

Samien 1 bulan lalu
induk
melakukan
a4728af9a0

+ 2 - 2
desktop/environment-host.mjs

@@ -14,7 +14,7 @@ import {WindowsInput,windowsFrame,sameWindowsDisplay} from './windows-input.mjs'
 
 /** Command execution shared by the loopback server and remote computers. */
 export class DesktopHostCore {
- constructor({root,dataRoot}){this.root=root;this.dataRoot=dataRoot;this.home=homedir();this.environmentReporter=new HostEnvironmentReporter(this.home);this.id=randomUUID();this.results=[];this.running=new Set();this.closed=false;this.browser=new BrowserEnvironment(join(dataRoot,'bot-browsers'));this.approved=new Set();this.approvedFull=new Set();this.permissionsFile=join(dataRoot,'computer-permissions.json');try{const stored=JSON.parse(readFileSync(this.permissionsFile,'utf8'));const screen=Array.isArray(stored)?stored:stored.screen;const full=Array.isArray(stored)?[]:stored.full;if(Array.isArray(screen))for(const key of screen)if(/^[a-f0-9]{64}$/.test(key))this.approved.add(key);if(Array.isArray(full))for(const key of full)if(/^[a-f0-9]{64}$/.test(key))this.approvedFull.add(key)}catch{}this.frames=new Map();this.shells=new Set();this.platform=desktopPlatform();this.native=this.platform.windows?new WindowsInput(root,app.isPackaged):undefined;this.windowsScreen=false;this.locked=false;this.lastProbe=0;this.onLock=()=>{this.locked=true;this.windowsScreen=false;this.native?.cancel();this.frames.clear()};this.onUnlock=()=>{this.locked=false;this.lastProbe=0};if(this.platform.windows){powerMonitor.on('lock-screen',this.onLock);powerMonitor.on('unlock-screen',this.onUnlock)}}
+ constructor({root,dataRoot}){this.root=root;this.dataRoot=dataRoot;this.home=homedir();this.environmentReporter=new HostEnvironmentReporter(this.home);this.id=randomUUID();this.results=[];this.running=new Set();this.closed=false;this.browser=new BrowserEnvironment(join(dataRoot,'bot-browsers'));this.approved=new Set();this.approvedFull=new Set();this.permissionsFile=join(dataRoot,'computer-permissions.json');try{const stored=JSON.parse(readFileSync(this.permissionsFile,'utf8'));const screen=Array.isArray(stored)?stored:stored.screen;const full=Array.isArray(stored)?[]:stored.full;if(Array.isArray(screen))for(const key of screen)if(/^[a-f0-9]{64}$/.test(key))this.approved.add(key);if(Array.isArray(full))for(const key of full)if(/^[a-f0-9]{64}$/.test(key))this.approvedFull.add(key)}catch{}this.frames=new Map();this.shells=new Set();this.platform=desktopPlatform();this.native=this.platform.windows?new WindowsInput(root,app.isPackaged):undefined;this.windowsScreen=false;this.locked=false;this.lastProbe=0;this.onLock=()=>{this.locked=true;this.windowsScreen=false;this.native?.cancel();this.frames.clear()};this.onUnlock=()=>{this.locked=false;this.lastProbe=0};this.onDisplayChanged=()=>{this.frames.clear();this.lastProbe=0};if(this.platform.windows){powerMonitor.on('lock-screen',this.onLock);powerMonitor.on('unlock-screen',this.onUnlock);for(const event of ['display-added','display-removed','display-metrics-changed'])screen.on(event,this.onDisplayChanged)}}
  transfers=new Map()
  info(){return {id:this.id,name:hostname(),platform:process.platform,screen:this.platform.windows?this.windowsScreen:process.platform==='darwin'&&systemPreferences.getMediaAccessStatus('screen')==='granted',accessibility:this.platform.windows?this.native.available:process.platform==='darwin'&&systemPreferences.isTrustedAccessibilityClient(false),approved:[...this.approved],full:[...this.approvedFull],...this.environmentReporter.fields()}}
  async prepareInfo(force=false){
@@ -88,7 +88,7 @@ export class DesktopHostCore {
   if(this.native){if(!sameWindowsDisplay(f,d,screen.dipToScreenRect(null,d.bounds)))throw new Error('显示器已变化,请刷新画面');return this.native.call({action:c.action,frame:f})}
   return new Promise((resolve,reject)=>{const child=execFile(join(this.root,app.isPackaged?'computer-helper':'computer-helper-dev'),[],{timeout:10000,maxBuffer:65536},(error,stdout,stderr)=>{if(error)reject(new Error(stderr.trim()||'系统未接受电脑输入'));else{try{resolve(JSON.parse(stdout))}catch{reject(new Error('电脑助手响应无效'))}}});child.stdin.end(JSON.stringify({action:c.action,frame:f}))})
  }
- async close(){this.closed=true;powerMonitor.removeListener('lock-screen',this.onLock);powerMonitor.removeListener('unlock-screen',this.onUnlock);await this.recycle()}
+ async close(){this.closed=true;powerMonitor.removeListener('lock-screen',this.onLock);powerMonitor.removeListener('unlock-screen',this.onUnlock);for(const event of ['display-added','display-removed','display-metrics-changed'])screen.removeListener(event,this.onDisplayChanged);await this.recycle()}
 }
 
 export class DesktopEnvironmentHost {

+ 2 - 1
desktop/windows-shell.mjs

@@ -14,7 +14,8 @@ export function windowsShell(shell, command, { cwd, timeout, signal, helper = re
     const output = { stdout: [], stderr: [] }, sizes = { stdout: 0, stderr: 0 }
     const capture = (key, bytes) => {
       const kept = bytes.subarray(0, Math.max(0, limit - sizes[key]))
-      output[key].push(kept); sizes[key] += kept.length
+      if (kept.length) output[key].push(kept)
+      sizes[key] += kept.length
     }
     const finish = async (code, error) => {
       if (finished) return

+ 47 - 2
desktop/windows-smoke.test.mjs

@@ -28,7 +28,7 @@ test('packaged Windows client logs in, encrypts pairing, restores its session an
         exchanges++; json({ commands: [], capabilities: { environmentMetadata: 1, desktopPlatforms: ['darwin', 'win32'] } })
       }); return
     }
-    res.setHeader('content-type', 'text/html; charset=utf-8'); res.end('<title>Windows 登录验证</title><h1>远程工作区</h1>')
+    res.setHeader('content-type', 'text/html; charset=utf-8'); res.end('<title>Windows 登录验证</title><h1>远程工作区</h1><textarea id="native-input" style="width:400px;height:140px"></textarea>')
   })
   await new Promise(done => server.listen(0, '127.0.0.1', done))
   const url = `http://127.0.0.1:${server.address().port}`
@@ -55,6 +55,51 @@ test('packaged Windows client logs in, encrypts pairing, restores its session an
     assert.equal((await readdir(home)).includes('service-instance.json'), false)
     const menu = await app.evaluate(({ Menu }) => ['desktop-host-use-local', 'desktop-stop-and-quit', 'runner-import'].map(id => Boolean(Menu.getApplicationMenu().getMenuItemById(id))))
     assert.deepEqual(menu, [false, false, false])
+    const native = await app.evaluate(async ({ app, BrowserWindow, screen }, { home, url }) => {
+      const { pathToFileURL } = process.getBuiltinModule('node:url')
+      const { join } = process.getBuiltinModule('node:path')
+      const { DesktopHostCore } = await import(pathToFileURL(join(app.getAppPath(), 'environment-host.mjs')).href)
+      const core = new DesktopHostCore({ root: join(process.resourcesPath, 'runtime'), dataRoot: join(home, 'native-acceptance') })
+      const owner = 'a'.repeat(64), resource = 'b'.repeat(64)
+      const command = { owner, resource, mode: 'local', profile: 'temporary', deadline: Date.now() + 30000 }
+      core.approved.add(owner); core.approvedFull.add(owner)
+      try {
+        const shell = await core.execute({ ...command, operation: 'shell', action: { command: "[Console]::WriteLine('原生命令')" } })
+        if (shell.exitCode !== 0 || shell.stdout.trim() !== '原生命令') throw new Error('打包后的 PowerShell 助手执行失败:' + shell.stderr)
+        await core.execute({ ...command, mode: 'browser', operation: 'open' })
+        await core.execute({ ...command, mode: 'browser', operation: 'browser', action: { kind: 'navigate', url } })
+        const browserFrame = await core.execute({ ...command, mode: 'browser', operation: 'view' })
+        if (!browserFrame.data || (await core.browser.sessions.get(resource).cookies.get({ url })).some(cookie => cookie.name === 'session')) throw new Error('独立浏览器画面或会话隔离失败')
+        let unavailable
+        try { await core.native.call({ operation: 'probe' }) } catch (error) { unavailable = error.message }
+        if (unavailable) return { shell: true, isolatedBrowser: true, screenInput: 'unavailable in CI session', reason: unavailable }
+        const frame = await core.execute({ ...command, operation: 'view' }), display = screen.getPrimaryDisplay()
+        const target = BrowserWindow.getAllWindows().find(w => w.isVisible() && w.webContents.getURL().startsWith(url)), bounds = target.getContentBounds()
+        target.show(); target.focus()
+        const rect = await target.webContents.executeJavaScript('(()=>{const r=document.querySelector("textarea").getBoundingClientRect();return {x:r.x+30,y:r.y+30}})()')
+        const physical = screen.dipToScreenPoint({ x: Math.round(bounds.x + rect.x), y: Math.round(bounds.y + rect.y) })
+        const primary = screen.dipToScreenRect(null, display.bounds)
+        await core.execute({ ...command, operation: 'input', frame, action: { kind: 'click', x: Math.floor(physical.x * frame.width / primary.width), y: Math.floor(physical.y * frame.height / primary.height) } })
+        await core.execute({ ...command, operation: 'input', frame, action: { kind: 'text', text: '你好,Windows🙂' } })
+        for (let i = 0; i < 30; i++) {
+          if (await target.webContents.executeJavaScript('document.querySelector("textarea").value') === '你好,Windows🙂') break
+          await new Promise(done => setTimeout(done, 100))
+        }
+        if (await target.webContents.executeJavaScript('document.querySelector("textarea").value') !== '你好,Windows🙂') throw new Error('原生鼠标点击或 Unicode 输入失败')
+        await core.execute({ ...command, operation: 'input', frame, action: { kind: 'key', key: 'a', modifiers: ['ctrl'] } })
+        await core.execute({ ...command, operation: 'input', frame, action: { kind: 'text', text: '组合键已验证' } })
+        if (await target.webContents.executeJavaScript('document.querySelector("textarea").value') !== '组合键已验证') throw new Error('原生 Ctrl+A 输入失败')
+        screen.emit('display-metrics-changed', {}, display, ['scaleFactor'])
+        let staleRejected = false
+        try { await core.execute({ ...command, operation: 'input', frame, action: { kind: 'key', key: 'Tab' } }) } catch (error) { staleRejected = /刷新画面/.test(error.message) }
+        if (!staleRejected) throw new Error('显示器变化后必须重新截图')
+        await core.revoke()
+        let revoked = false
+        try { await core.execute({ ...command, operation: 'shell', action: { command: 'exit 0' } }) } catch { revoked = true }
+        if (!revoked) throw new Error('撤销后仍允许命令执行')
+        return { shell: true, isolatedBrowser: true, screenshot: true, click: true, unicodeInput: true, ctrlA: true, displayInvalidation: true, revoked: true, scaleFactor: display.scaleFactor }
+      } finally { await core.close() }
+    }, { home, url })
     await mkdir(evidence, { recursive: true }); await page.screenshot({ path: join(evidence, 'remote-login.png') })
     await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].close())
     assert.equal(await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].isVisible()), false)
@@ -62,6 +107,6 @@ test('packaged Windows client logs in, encrypts pairing, restores its session an
     const before = exchanges; app = await launch(); page = await app.firstWindow()
     await page.waitForURL(url + '/**'); await expect.poll(() => exchanges).toBeGreaterThan(before)
     await writeFile(join(evidence, 'smoke.json'), JSON.stringify({ platform: 'win32', login: true, encryptedPairing: true, sessionRestored: true,
-      clientOnly: true, nativeDesktopControl: 'requires interactive Windows 10/11 acceptance', upgradeReplacement: 'requires two-version installed-app acceptance' }, null, 2))
+      clientOnly: true, native, windows10and11Matrix: 'requires Windows 10/11 acceptance; CI runs Windows Server 2022' }, null, 2))
   } finally { await app?.close().catch(() => {}); await new Promise(done => server.close(done)); await rm(home, { recursive: true, force: true }) }
 })

+ 1 - 1
desktop/windows.test.mjs

@@ -93,7 +93,7 @@ test('native Windows PowerShell preserves Chinese, exit status and cancellation'
     const result = await execHostShell(root, { command: childCommand + (wait ? '; Start-Sleep -Seconds 60' : ''), timeoutMs: 3000 })
     assert.equal(result.timedOut, wait)
     const pid = Number(result.stdout.trim()); assert.ok(pid > 0, result.stderr)
-    const alive = await execHostShell(root, { command: `if (Get-Process -Id ${pid} -ErrorAction SilentlyContinue) { exit 1 }` })
+    const alive = await execHostShell(root, { command: `if (Get-Process -Id ${pid} -ErrorAction SilentlyContinue) { exit 1 } else { exit 0 }` })
     assert.equal(alive.exitCode, 0, 'PowerShell 子进程在结束或超时后必须退出')
   }
 })

+ 4 - 4
src/client/components/app/DesktopHostSettingsPanel.vue

@@ -27,7 +27,7 @@ async function register() {
     if(web.protocol==='http:'&&!['localhost','127.0.0.1','[::1]'].includes(web.hostname)&&!insecure.value)throw new Error('远程电脑请使用 HTTPS,或勾选可信局域网 HTTP')
     const result = await apiRequest<{host:DesktopHostSummary;token:string}>('/api/app/admin/desktop-hosts', {method:'POST',body:{name:name.value.trim()}})
     configuration.value = {protocol:1,serverURL:web.origin,hostId:result.host.id,token:result.token,...(insecure.value?{allowInsecureLan:true}:{})}
-    notice.value = '电脑已注册,请下载配置并在那台电脑 的夭夭 App 中导入。'
+    notice.value = '电脑已注册,请下载配置并在那台电脑的夭夭 App 中导入。'
     await refresh()
   })
 }
@@ -49,7 +49,7 @@ onMounted(()=>{void action(refresh)})
 
 <template>
   <section class="desktop-host-panel" aria-label="电脑设置">
-    <p>在其他电脑 上安装夭夭 App 并导入电脑配置后,那台电脑会主动连接服务器,机器人即可操作它的真实桌面。每台电脑单独授权,配置凭据仅注册时下发一次。</p>
+    <p>在其他电脑上安装夭夭 App 并导入电脑配置后,那台电脑会主动连接服务器,机器人即可操作它的真实桌面。每台电脑单独授权,配置凭据仅注册时下发一次。</p>
     <div class="host-toolbar"><strong>已注册电脑</strong><button type="button" :disabled="busy" @click="action(refresh)">刷新状态</button></div>
     <ul v-if="hosts.length" class="host-list">
       <li v-for="host in hosts" :key="host.id">
@@ -62,13 +62,13 @@ onMounted(()=>{void action(refresh)})
       <label>电脑名称<input v-model="name" required maxlength="100" autocomplete="off" placeholder="书房 iMac" :disabled="busy"></label>
       <small>用于在电脑面板中区分各台机器。</small>
       <label>夭夭服务地址<input v-model="serverURL" type="url" required :disabled="busy"></label>
-      <small>必须能从那台电脑 访问。</small>
+      <small>必须能从那台电脑访问。</small>
       <label class="host-check"><input v-model="insecure" type="checkbox" :disabled="busy">使用可信局域网 HTTP</label>
       <button class="solid-button" :disabled="busy || !name.trim()">注册电脑</button>
     </form>
     <div v-else class="host-setup">
       <button class="solid-button" type="button" @click="download">下载电脑配置</button>
-      <p>配置包含这台电脑的连接凭据,仅本次可下载。在那台电脑 上打开夭夭 App,通过菜单「电脑 → 导入电脑配置…」选择下载的 desktop-host.json。</p>
+      <p>配置包含这台电脑的连接凭据,仅本次可下载。在那台电脑上打开夭夭 App,通过菜单「电脑 → 导入电脑配置…」选择下载的 desktop-host.json。</p>
       <small>配置丢失后可停用电脑并重新注册;停用立即断开那台电脑。</small>
     </div>
     <p v-if="error" class="host-error" role="alert">{{ error }}</p>

+ 1 - 1
src/client/components/app/DesktopModePanel.vue

@@ -13,7 +13,7 @@ const options = computed(() => ([
   { mode: 'client', title: '客户端模式', description: '连接另一台电脑上的夭夭服务器。本机无需安装 Hermes。', icon: 'link' },
   { mode: 'server', title: '服务器模式', description: '在这台电脑上运行夭夭服务,使用本机 Hermes,并供其他客户端连接。', icon: 'monitor' },
 ] as const).filter(option => !state.value?.supportedModes || state.value.supportedModes.includes(option.mode)))
-const canSwitch = computed(() => options.value.length > 1)
+const canSwitch = computed(() => !!state.value && options.value.length > 1)
 
 async function load() {
   busy.value = true; error.value = ''

+ 1 - 1
src/client/components/workspace/LocalVmChatPanel.vue

@@ -153,7 +153,7 @@ onBeforeUnmount(()=>{closed=true;revision++;previewRevision++;clearTimeout(timer
        <button v-if="host.online&&!host.local.ready" class="primary" :disabled="busy||!isAdmin" @click="authorizeHost(host.id)">在桌面端授权</button>
        <p v-if="host.online&&!host.local.ready" class="hint">{{host.platform==='win32'?'在 Windows 客户端确认授权,并解锁电脑、保持桌面会话连接。':'在目标 Mac 上确认授权,并按系统提示开启屏幕录制和辅助功能;可能需要重新打开 App。'}}</p>
        <button v-if="host.online&&!host.local.fullAuthorized" :disabled="busy||!isAdmin" @click="authorizeHost(host.id,'full')">授权文件与命令</button>
-       <p v-if="host.online&&!host.local.fullAuthorized" class="hint">授权后,机器人可以在这台电脑 的用户主目录内读写文件,并以当前用户权限执行命令;每台电脑单独授权,可随时撤销。</p>
+       <p v-if="host.online&&!host.local.fullAuthorized" class="hint">授权后,机器人可以在这台电脑的用户主目录内读写文件,并以当前用户权限执行命令;每台电脑单独授权,可随时撤销。</p>
       </div>
       <button v-if="serverHost" class="primary" :disabled="busy||!serverHost.online||!serverHost.local.ready" @click="openDesktop('desktop','local')">接管服务器</button>
     </template>