一个 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 点击控制未开启:电脑上的访问用户名是空的 |
六、上手三步
- 右键 → 以管理员身份运行。服务绑的是
http://+:8080/(监听所有网卡),不是管理员会直接启动失败。 - 点启动服务。状态行会打印手机真正能访问的地址——取的是默认路由那张网卡的 IPv4,多网卡时其余地址也会列出来,避免给你个 VPN 地址。
- 手机连同一个路由器/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 太麻烦。
工具不需要大,能在你需要的时候把信息递到手边,就够了。