environment-host.mjs 7.3 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556
  1. import {app,desktopCapturer,dialog,screen,shell,systemPreferences} from 'electron'
  2. import {hostname} from 'node:os'
  3. import {readFileSync,writeFileSync,mkdirSync} from 'node:fs'
  4. import {join} from 'node:path'
  5. import {randomUUID} from 'node:crypto'
  6. import {request} from 'node:http'
  7. import {execFile} from 'node:child_process'
  8. import {BrowserEnvironment} from './browser-environment.mjs'
  9. function exchange(record,body){return new Promise((resolve,reject)=>{const url=new URL('/desktop/environment',record.url);if(url.protocol!=='http:'||url.hostname!=='127.0.0.1')return reject(new Error('桌面服务地址无效'));const bytes=Buffer.from(JSON.stringify(body)),req=request(url,{method:'POST',headers:{'x-yaoyao-desktop-token':record.token,'content-type':'application/json','content-length':bytes.length}},res=>{let size=0;const chunks=[];res.on('data',chunk=>{size+=chunk.length;if(size>256*1024)res.destroy(new Error('桌面命令超过限制'));else chunks.push(chunk)});res.on('error',reject);res.on('end',()=>{try{if(res.statusCode!==200)throw new Error('桌面服务未连接');resolve(JSON.parse(Buffer.concat(chunks)))}catch(error){reject(error)}})});req.on('error',reject);req.setTimeout(5000,()=>req.destroy(new Error('桌面连接超时')));req.end(bytes)})}
  10. /** Command execution shared by the loopback host and remote desktop hosts. */
  11. export class DesktopHostCore {
  12. constructor({root,dataRoot}){this.root=root;this.dataRoot=dataRoot;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.permissionsFile=join(dataRoot,'computer-permissions.json');try{const list=JSON.parse(readFileSync(this.permissionsFile,'utf8'));if(Array.isArray(list))for(const key of list)if(/^[a-f0-9]{64}$/.test(key))this.approved.add(key)}catch{}this.frames=new Map()}
  13. info(){return {id:this.id,name:hostname(),platform:process.platform,screen:process.platform==='darwin'&&systemPreferences.getMediaAccessStatus('screen')==='granted',accessibility:process.platform==='darwin'&&systemPreferences.isTrustedAccessibilityClient(false),approved:[...this.approved]}}
  14. async revoke(){this.approved.clear();this.save();this.frames.clear();this.id=randomUUID()}
  15. async recycle(){this.results=[];this.id=randomUUID();await this.browser.close()}
  16. save(){mkdirSync(this.dataRoot,{recursive:true,mode:0o700});writeFileSync(this.permissionsFile,JSON.stringify([...this.approved]),{mode:0o600})}
  17. takeResults(){const sent=this.results.slice();this.results.splice(0,sent.length);return sent}
  18. async handle(commands){for(const c of commands??[]){if(this.running.has(c.id))continue;this.running.add(c.id);const epoch=this.id;void this.execute(c).then(value=>{if(epoch===this.id)this.results.push({id:c.id,value})},error=>{if(epoch===this.id)this.results.push({id:c.id,error:String(error.message).slice(0,500)})}).finally(()=>this.running.delete(c.id))}}
  19. async execute(c){const epoch=this.id;if(Date.now()>c.deadline||this.closed)throw new Error('桌面请求已过期');if(!/^[a-f0-9]{64}$/.test(c.owner))throw new Error('桌面请求身份无效')
  20. if(c.operation==='authorize'){
  21. if(process.platform!=='darwin')throw new Error('此电脑暂不支持本机控制')
  22. const {response}=await dialog.showMessageBox({type:'question',title:'授权本机控制',message:`允许夭夭账号「${String(c.account??'当前账号').slice(0,100)}」的机器人查看和操作这台 Mac?`,detail:'授权后,机器人和该账号的 Web、手机端可以查看屏幕,并在接管时操作鼠标和键盘。你可以在夭夭菜单中撤销。',buttons:['取消','允许并检查系统权限'],defaultId:0,cancelId:0})
  23. if(response!==1||Date.now()>c.deadline||this.closed||epoch!==this.id)throw new Error('本机控制授权已取消')
  24. this.approved.add(c.owner);this.save()
  25. if(!systemPreferences.isTrustedAccessibilityClient(false))systemPreferences.isTrustedAccessibilityClient(true)
  26. if(systemPreferences.getMediaAccessStatus('screen')!=='granted'){await desktopCapturer.getSources({types:['screen'],thumbnailSize:{width:1,height:1}}).catch(()=>{});if(systemPreferences.getMediaAccessStatus('screen')!=='granted')await shell.openExternal('x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture')}
  27. return {ok:true}
  28. }
  29. if(!['local','browser'].includes(c.mode)||!['persistent','temporary'].includes(c.profile)||!['open','view','input','browser','browser-state'].includes(c.operation))throw new Error('桌面命令无效')
  30. if(c.mode==='browser'){
  31. if(!/^[a-f0-9]{64}$/.test(c.resource))throw new Error('浏览器资源无效')
  32. if(c.operation==='browser-state')return this.browser.state(c.resource)
  33. if(c.operation==='open'){await this.browser.open(c.resource,c.profile);return {ok:true}}
  34. if(c.operation==='browser')return this.browser.action(c.resource,c.profile,c.action)
  35. if(c.operation==='view')return this.browser.view(c.resource)
  36. return this.browser.input(c.resource,c.action,c.frame)
  37. }
  38. const info=this.info();if(!info.approved.includes(c.owner)||!info.screen||!info.accessibility)throw new Error('请在桌面端授权,并在系统设置开启屏幕录制和辅助功能')
  39. if(c.operation==='open')return {ok:true}
  40. if(c.operation==='view'){const d=screen.getPrimaryDisplay(),sources=await desktopCapturer.getSources({types:['screen'],thumbnailSize:{width:1600,height:1200}}),source=sources.find(s=>s.display_id===String(d.id));if(!source||source.thumbnail.isEmpty())throw new Error('系统尚未允许读取屏幕');const dimensions=source.thumbnail.getSize();this.frames.set(c.resource,{display:d.id,...dimensions,bounds:d.bounds});return {data:source.thumbnail.toPNG().toString('base64'),...dimensions}}
  41. if(c.operation!=='input')throw new Error('本机操作无效')
  42. const f=this.frames.get(c.resource),d=screen.getPrimaryDisplay();if(!f||f.display!==d.id||JSON.stringify(f.bounds)!==JSON.stringify(d.bounds)||f.width!==c.frame?.width||f.height!==c.frame?.height)throw new Error('显示器已变化,请刷新画面')
  43. 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}))})
  44. }
  45. async close(){this.closed=true;await this.recycle()}
  46. }
  47. export class DesktopEnvironmentHost {
  48. constructor({manager,root,dataRoot}){this.manager=manager;this.core=new DesktopHostCore({root,dataRoot});this.closed=false;this.timer=undefined;this.instance=undefined}
  49. revoke(){return this.core.revoke()}
  50. async cycle(){if(this.closed)return;try{const record=await this.manager.readRecord();if(record){const verified=await this.manager.verify(record);record.url=verified.url;if(this.instance&&this.instance!==record.instanceId)await this.core.recycle();this.instance=record.instanceId;const value=await exchange(record,{host:this.core.info(),results:this.core.takeResults()});await this.core.handle(value.commands??[])}}catch{}finally{if(!this.closed)this.timer=setTimeout(()=>void this.cycle(),400)}}
  51. start(){this.closed=false;void this.cycle()}
  52. async close(){this.closed=true;clearTimeout(this.timer);await this.core.close()}
  53. }