ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

开源 DeskPeek:把手机变成自己电脑的小监视器

开源 DeskPeek:把手机变成自己电脑的小监视器

一个 Windows 单文件小工具:手机浏览器直接看自己电脑的屏幕直播,需要时还能点一下。手机上不装 App、没有账号、不要配对码。


一、为什么要做这个

场景很具体:电脑上正跑着一个十几分钟才出结果的活——AI 编程 agent 在改代码、dotnet build在编、几十 GB 的东西在下载、某个脚本在等回调。人已经从书桌挪到沙发上了,但手边只有手机,而你就是想瞄一眼"到哪一步了""是不是卡住了""要不要点那个继续"。

我先把现成方案都试了一圈,没有一个合这个尺度的需求:

方案为什么没选
RustDesk / ToDesk / TeamViewer两端都要装,要账号或配对码。我只要"看一眼 + 点一下",用不上完整桌面串流
Windows 远程桌面家庭版根本不带 RDP 服务端;就算带,手机上是按键盘鼠标设计的交互,触摸很难受
VNC同样要装服务端,手机还得装 Viewer
定时截图刷新网页能看不能点,延迟难看

于是写了DeskPeek:电脑上跑一个小 exe,手机浏览器打开一个网址就完事。

现在用得最多的功能,就是第一条场景——躺着盯 AI agent 在电脑上干活,需要点"接受 / 继续 / 运行"的时候从手机上点一下。


二、它能做什么,不能做什么

能:

  • 看:整块虚拟屏幕(含多显示器)实时直播;帧率 1–30fps(默认 10)、JPEG 质量 10–100(默认 60)、可缩到 1280 宽省流量(默认开)
  • 点:手机点一下 = 电脑左键单击一次。要显式开启,且必须设访问口令
  • 稳:断流自动重连;直播期间阻止系统睡眠;防火墙规则跟着端口走
  • 走:可以顺手把服务通过 frp 转到公网(自备 frpc,软件负责写配置和拉起)

不能(说在前面,比让用户自己撞到好):

  • 只有左键单击。没有键盘、拖拽、右键、滚轮
  • 锁屏界面和 UAC 提权弹窗点不了—— Windows 不允许向安全桌面注入输入,这是系统级限制,不是 bug
  • 没有声音、没有多路画面、没有文件传输
  • 只跑在 Windows 上(抓屏用的 GDI);手机端不需要装任何东西

三、技术选型:为什么是 MJPEG

整个链路一句话就能说完:

GDI 抓屏 → GDI+ 压 JPEG → multipart/x-mixed-replace 一路吐 → 手机 <img> 直接播

MJPEG 最被低估的点是:<img src="/stream">这一行不需要任何 JavaScript、不需要播放器、不需要信令服务器。浏览器原生就认"一个持续替换画面的图片响应",这是 WebRTC 和 WebSocket 都换不来的省事。

被问过要不要上更"现代"的方案,都比一比:

方案真实代价
WebRTC信令服务 + ICE/TURN + SRTP + 编解码协商。为"瞄一眼"太贵
WebSocket + canvas要么在 JS 里自己解码 JPEG,要么每帧转 blob URL;心跳、重连、背压全得自己写——而这些为了 MJPEG 我照样要写
截图轮询每帧一次 HTTP 请求,延迟和流量都不好看
DXGI Desktop Duplication帧率上限更高,但要引 D3D11 互操作,还要处理输出复制的独占语义。10fps 够用的场景不值得

服务端就是.NET Framework 4.7.2 + WinForms +HttpListener,零第三方依赖,产物一百多 KB 一个 exe。

选 Framework 而不是 .NET 8 的理由非常现实:系统自带运行时,不用打包 60MB 的 self-contained;而 WinForms 设计器拖控件改界面,比手写布局代码快一个数量级——这种小工具的界面改动极其频繁。


四、真正花时间的地方:九个坑

功能本身一天就跑起来了,剩下的时间全在处理"为什么手机上看着不对"。这部分才是我觉得值得写出来的。

4.1 "有时候连不上"其实是电脑睡了

不是 bug,是电源策略。机器进了 S3 之后网络栈就没了,手机当然打不开,而现象呈现为"时好时坏",特别像代码问题。

现在服务运行期间会调用:

SetThreadExecutionState(ES_CONTINUOUS | ES_SYSTEM_REQUIRED);

只拦系统睡眠,不碰显示器电源策略(ES_DISPLAY_REQUIRED会把屏幕也钉住,那是在替你费电)。停止服务或退出程序立刻恢复。

4.2 MJPEG 断了,浏览器不会自己重连

这条最坑:Wi-Fi 抖一下、电脑睡醒一次、手机切到后台,流都会断。而断了之后画面永久停在最后一帧,用户看到的结论就是"连不上",可服务端其实活得好好的。

浏览器对multipart/x-mixed-replace没有任何自动恢复机制,所以只能自己做:

  • 每次开流分配一个 generation token,回调里比对,避免旧流的重试把新流干掉
  • onload后 10 秒内没等到新帧就判定断开(帧率下限 1fps,10 秒足以区分"卡"和"断")
  • 按 1s → 2s → 4s → 8s 指数退避重连
  • visibilitychange:切后台停止重试,回前台立即重开
  • 请求 URL 带时间戳。URL 一模一样的话,有些浏览器会复用那条已经死掉的连接

4.3 防火墙规则不跟端口走

Windows 防火墙按"程序"放行看着够用,但只要换端口、或者网卡被归到 Public profile,就会出现"电脑这边一切正常、手机就是打不开"。

现在启动时按当前端口重建自己那条入站规则(DeskPeek-ScreenLive,先删再加,避免残留旧端口)。而且绝不去动用户自己建的规则。

netsh的中文输出没法可靠解析,所以只判断退出码:失败就在状态行追加一句"防火墙放行失败,请手动放行 TCP xxxx"。不弹窗打断,也不静默失败。

4.4 DPI 缩放吃掉画面右下角

系统 150% 缩放时,进程如果不声明 DPI 感知,SystemInformation.VirtualScreen拿到的是缩放后的逻辑尺寸,抓出来的图右下各缺一块。

在app.manifest里声明dpiAwareness=System之后拿到的才是物理像素,另外用GetDeviceCaps(DESKTOPHORZRES / DESKTOPVERTRES)校验一次。

4.5 多显示器负坐标,点击落点跑偏

副屏在主屏左边时VirtualScreen.X是负数。点击控制一开始传的是像素坐标,结果一开"缩小到 1280 宽"、或者手机横竖屏一换,落点就飘。

最后改成传 0~1 的画面比例,像素留给服务端算:

int x = vs.X + (int)Math.Round(fx * (vs.Width - 1)); // vs = SystemInformation.VirtualScreen

这样缩放开关、系统 DPI、多屏原点全都跟它无关——因为Cursor.Position和VirtualScreen本来就是同一个坐标空间。

4.6 iPhone Safari 不支持屏幕旋转,得用 CSS 兜底

Android Chrome 走requestFullscreen()+screen.orientation.lock('landscape')就完事了。iOS Safari 没有 Fullscreen API,只能把整个画面容器transform: rotate(90deg)硬画出来。

代价是触摸坐标要做轴交换——旋转 90° 之后视口轴和图片轴互换了:

if (rotated) { x = sy / r.height; y = 1 - sx / r.width; }
else { x = sx / r.width; y = sy / r.height; }

这行公式是拿浏览器自己的 transform matrix 反算验证过的,不是猜的。另外 iOS 的100vh不随横屏和工具栏收放变化,所以布局一律用window.visualViewport的真实尺寸。

4.7 横屏之后回不到竖屏

第一版横屏时把顶部工具栏visibility:hidden掉了,自作聪明觉得这样沉浸。结果走 CSS 兜底的机器一进横屏就没有任何按钮可点,永久回不去,只能杀掉页面重来。

现在横屏时工具栏变成浮在左上角的半透明小条(避开刘海安全区),"还原"和"触控"随时点得到;退出横屏时额外调一次screen.orientation.unlock(),因为有些浏览器退出全屏后方向锁定还留着。

4.8 微信内置浏览器不弹认证框

设了访问口令走的是 HTTP Basic。桌面浏览器会弹登录框,但微信这类内置浏览器不允许弹,于是它把我们 401 的正文当成网页内容直接显示出来——用户看到的是屏幕上孤零零一行401 需要访问口令,然后来问我"为什么连不上"。

解法是老老实实加一个登录页:

  • /未登录时返回一个正常 HTML 表单,POST 到/login
  • 校验通过发一个随机 32 位 hex 的HttpOnly会话 cookie,12 小时滑动续期(整夜看不会被踢下线)
  • 之后<img>的长连接和fetch('/click')都自动带上它
  • Basic 认证同时保留,桌面浏览器里存过口令的体验不变
  • 不再发WWW-Authenticate挑战,否则某些浏览器还是会去弹那个它弹不出来的框

页面上还加了兜底:断流重连和点击都会探一次/auth-check,登录态过期就自动跳回登录页,不会卡在"连接中断"里无限重试。

4.9 跨线程读控件,症状是"明明勾了却没生效"

HttpListener的请求处理跑在线程池线程上,而鉴权逻辑要读"允许手机点击控制"这个复选框和两个口令输入框。从非 UI 线程读控件属性本身就是非法跨线程访问,挂着调试器时会抛异常,而异常被请求外层的try/catch吞掉了。

现在改成 UI 线程改一处就同步一份快照字段,请求线程只读快照,行为跟有没有调试器、有没有断点都无关。


五、安全上做了什么取舍

点击控制等于把这台电脑的操作权交出去,所以这里的默认值全都偏保守:

机制说明
三条件缺一不可勾选开关 + 用户名非空 + 密码非空。少哪一项,403 文案就点名哪一项,状态行同步显示,不用你猜
手机端"触控"默认关闭按钮变橙色才发包,防止口袋误触和页面重试打串
限流/click最快 100ms 一次(约每秒 10 次),超了返回429 点击太快
会话管理cookie 随机 32 位 hex、HttpOnly、SameSite=Lax;改口令或停止服务,旧登录态立即作废
公网警告开着 frp 转发又开着点击控制时,状态行直接写"公网那端可以试口令并直接操作这台电脑"
明文这一点不遮掩全程 HTTP。局域网内够用;要过公网就设强口令,或者只看不点

403 长这样,故意写成"缺什么"而不是复读需求:

403 点击控制未开启:电脑上的访问用户名是空的

六、上手三步

  1. 右键 → 以管理员身份运行。服务绑的是http://+:8080/(监听所有网卡),不是管理员会直接启动失败。
  2. 点启动服务。状态行会打印手机真正能访问的地址——取的是默认路由那张网卡的 IPv4,多网卡时其余地址也会列出来,避免给你个 VPN 地址。
  3. 手机连同一个路由器/Wi-Fi,浏览器打开那个地址,输口令,进画面。登录页是普通的网页表单,不是浏览器弹的认证框,所以在微信内置浏览器里也能登。

想用手机点击的话:电脑上勾选允许手机点击控制并把用户名密码都填上,手机上再点触控,然后点哪儿就在哪儿落一次左键:


七、源码与构建

项目信息详情
项目名称DeskPeek
开源地址https://gitee.com/known/DeskPeek
技术栈.NET Framework 4.7.2 + Windows Forms + HttpListener,零第三方依赖
许可证Apache 2.0
运行环境Windows(手机端任意浏览器,含微信内置浏览器)
MSBuild.exe -t:Rebuild -v:minimal DeskPeek.csproj
# 产物:bin\Debug\DeskPeek.exe

代码量很小,三个文件值得看:MainForm.cs(服务、鉴权、抓屏推流、点击注入、frp 集成)、ScreenCapture.cs(GDI 抓屏与物理分辨率)、内嵌的IndexHtml(手机端播放与重连、触摸坐标换算)。


八、接下来可能做的

  • 键盘输入 / 右键 / 滚轮(点击已经是"够用"的边界,再往上就是远程桌面了)
  • DXGI Desktop Duplication 提高帧率上限
  • 自签 TLS,让公网场景不再是明文
  • 手机端 PWA 化(加到主屏、独立窗口)

如果你有其它想法,欢迎开 issue 或直接改。


写在最后

做这个工具的动机小得不好意思说出口:我就是想在沙发上瞄一眼电脑上的编译跑到哪了。但正是这种"不值一提"的需求,最容易掉进现有工具的缝里——远程桌面太重,截图刷新太笨,装 App 太麻烦。

工具不需要大,能在你需要的时候把信息递到手边,就够了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进