ShadowCat:用 QR 码在浏览器之间离线传文件
posts posts 2026-05-23T03:15:00+08:00ShadowCat 是一个约 190 KB 的单文件 HTML 页面,把文件切成带序号的 QR 码帧循环播放,另一台设备用摄像头收齐后拼回原文件,全程无网络。本文对照 SPEC.md 与源码拆解 QRX1 帧格式、单向无回程的协议取舍和实际吞吐。技术笔记浏览器, QR 码, 文件传输ShadowCat:用 QR 码在浏览器之间离线传文件
有时候两台设备之间能用的通信手段只剩摄像头——比如一部老手机,蓝牙和 NFC 模块已经坏了,但屏幕和浏览器还活着。ShadowCat 解决的就是这个问题:在一个约 190 KB 的单文件 HTML 页面里,把任意文件切成一串 QR 码循环播放,另一台设备举着摄像头收,收齐后拼回原文件。整个过程不碰网络,官方把成品直接放在 shadowcat.online,两个浏览器互相扫就能用。
核心判断
ShadowCat 的价值不在快,在通。当 BLE、NFC、Wi-Fi 全部不可用时,屏幕和摄像头往往是最后剩下的两条硬件通路,这个项目把这两条通路接成了一条单向数据通道。
代价是协议必须做得极简。QRX1 协议(ShadowCat 的线上格式)没有确认帧、没有滑动窗口、没有任何回程信道——发送端不知道接收端收到了什么,靠的是把同一组帧无限循环下去,漏了的下一轮自然会补上。SPEC.md 第一节的表述是:“重传"就是等循环转回来,或者用 Show frame 手动显示某一个帧。这是理解整个设计的钥匙:它把可靠性问题从协议层挪到了时间层。
系统地图
发送端(任意浏览器) 光通道(单向,无 ACK) 接收端(任意浏览器)
───────────────────── ───────────────────── ─────────────────────
读文件 → CRC32(原始字节) ──► QR 码帧 @ N fps ──► 摄像头逐帧解码
(可选 gzip) → Base64 → 切片 循环 [header, D1…DN] 按 idx 去重入库
loop: header + 数据帧 forever forever,直到手动停 缺失网格实时显示
收齐 → 校验 → 下载页面本身有六个标签页,前两个处理单个 QR 码,后四个才是文件传输的主战场:
| 标签 | 职责 |
|---|---|
| Generate | 把一段文本编码成一个 QR 码 |
| Scan | 用摄像头解码一个 QR 码 |
| Send file | 选文件、设参数、可选 gzip,开始循环播放帧 |
| Start from | 从指定帧序号开始循环,之后正常回绕 |
| Show frame | 静态显示指定的某一个帧(编号对应接收端缺失网格,0 = header),用于补发 |
| Receive file | 开摄像头收帧,header 自动识别,收齐校验后出现下载按钮 |
QRX1 协议:七字段帧格式
每个 QR 码里装的是一行 UTF-8 文本,只有两种类型。header 帧(按现行 SPEC.md):
QRX1|H|<total>|<flags>|<filename>|<sizeBytes>|<crc32hex>数据帧:
QRX1|D|<idx>|<base64chunk>total:数据帧总数flags:逗号分隔的变换标记,默认为空;目前唯一定义的是gz(载荷经 gzip 压缩)filename:原始文件名(不允许含|)sizeBytes:线上载荷大小——设了gz时是压缩后的大小,否则是原始大小crc32hex:8 位小写十六进制的 CRC-32 校验值(循环冗余校验,IEEE 802.3,多项式 0xEDB88320),永远对解压前的原始字节计算idx:数据帧序号,从 1 开始
解析不需要任何转义规则:Base64 字母表里没有 |,文件名里禁止 |,所以整行按 split('|') 切开就完事。header 帧固定七个字段,字段数不对即视为畸形丢弃。
一个值得注意的演变:项目 2026 年 5 月 22 日发布时,header 只有六个字段(没有 flags 位);三天后的 5 月 26 日,gzip 压缩随 PR #6 加入,flags 插在了 total 和 filename 之间。SPEC.md 尾注里写得很直白:旧格式的六字段 header 过不了新的七字段长度检查,会被直接拒收——协议升级没有做任何兼容妥协。
flags 同时是 QRX1 的前向兼容机制。SPEC.md 的规划是:将来加加密、换压缩算法,都通过新增标记实现,不动 QRX1 前缀;接收端遇到不认识的标记必须拒收整个载荷,而不是忽略后解出乱码。真要出现不兼容变更,就走 QRX2 新前缀,旧接收端视而不见。
发送与接收的完整流转
把一次真实传输从头到尾走一遍,机制的配合方式是这样的:
- 发送端选一个 100 KB 左右的文本文件,保持默认参数(每帧 500 字符、3 fps、纠错级别 M),勾上 Compress (gzip)。
- 点开始后,页面先用
CompressionStream原生接口做 gzip(Chrome 80+、Firefox 113+、Safari 16.4+ 支持)。压完比原始小,载荷换成 gzip 结果,header 带上gz标记;要是压完反而更大——PDF、JPG、MP4 这类本就压缩过的内容常会这样——自动回退原始字节,界面提示 gzip skipped。 - 载荷 Base64 编码后按 500 字符切片,算出总帧数,拼成
[header, 数据帧 1…N]队列,按 3 fps 循环播放。header 每一轮都重发,接收端哪怕错过第一个 QR 码,下一轮还能拿到。 - 接收端扫到 header 后建立追踪状态:进度条开始填充,缺失网格列出还没到的序号。收到的数据帧按
idx入库,重复帧和越界序号直接丢弃;要是扫到了 CRC 或总帧数不同的新 header,说明发送端换了文件,状态整体重置。 - 大部分漏帧靠循环自愈。个别帧反复丢(反光、失焦),发送端在 Show frame 里输入那个序号,屏幕上静止显示这一个 QR 码,直到网格里它变绿。
- 全部收齐后,接收端拼接、Base64 解码、校验长度,带
gz就先解压,最后重算 CRC-32 与 header 比对。一致,出现 Download 按钮;不一致,给出红色错误横幅,不落盘。gzip 数据坏了也一样——解压失败是硬错误,不会退回原始字节。
分块与吞吐
README 给了一组参考工况下的读数:
| 参数 | 典型值 | 说明 |
|---|---|---|
| 每帧字符数 | 500(默认,允许 50–2000) | 以 Base64 字符计,500 字符 ≈ 375 字节原始数据 |
| 帧率 | 3 fps(可调 1–20,步进 0.5) | 默认值即参考工况 |
| 传输速率 | ≈ 1.1 KB/s(Base64)≈ 0.83 KB/s(原始) | 500 字符 × 3 fps 工况 |
| 100 KB 文件 | 一轮循环约 2 分钟 | 接收端通常需要 1–2 轮 |
这组数字测的是光通道的物理上限:QR 码能塞多少数据、相机每秒能解几帧。SPEC.md 给出了通用的估算公式——原始吞吐 ≈ (每帧字符数 × 3/4) × 帧率,字节每秒,未计帧头开销和漏帧。0.83 × 120 秒 ≈ 100 KB,与"两分钟一轮"正好自洽。反过来说,这些数字推不出"任何设备都能跑到 0.83 KB/s”,更推不出 MB 级文件可行——文件大十倍,等待就是二十分钟起步。
三个参数各管一头,互相牵制:
- 块大小:调大提速,但 QR 码变密,老设备解码失败率上升
- 帧率:调大提速,但接收端相机跟不上就成片漏帧
- 纠错级别(ECC):L/M/Q/H 四档,分别容忍 7%/15%/25%/30% 的码面损毁;级别越高越抗反光和抖动,QR 也越密
README 给老设备的保守配置是:降帧率、纠错升到 Q、块缩到约 300 字符——出来的 QR 更小更稀疏,牺牲速度换解码成功率。渲染报 “code length overflow” 时,降块大小或降纠错级别就能解决。
跑起来之前
有一道坎 README 单独强调了:摄像头所在的页面需要 HTTPS 或 localhost,file:// 直接打开拿不到 getUserMedia 权限。也就是说用摄像头收帧的那一端必须通过 HTTP(S) 访问页面,局域网里最简单的做法是在发送侧起一个静态服务:
python3 -m http.server 8000
# 另一台设备访问 http://<电脑 IP>:8000/shadowcat.htmliOS Safari 对跨设备摄像头访问额外要求 HTTPS,局域网得用 caddy 或自签证书。另外有个小陷阱:README 里这条命令写的还是 qrcode.html,那是项目早期的文件名,仓库里实际文件是 shadowcat.html(根目录的 index.html 只是一行 meta refresh 跳转)。照抄 README 会 404,文件名以仓库为准。
页面之外
仓库里比 HTML 页面更值得看的,是围绕协议的工程配套:
- SPEC.md:305 行的线格式规范,含帧结构图、发送/接收管道图和 12 条不变量表(比如"重复数据帧必须忽略而非覆盖"“未知标记必须拒收”),是判断任何实现行为的最终依据。
- tools/:纯 Python 参考实现。
qrx1.py只有协议原语;qrx1_encode.py能把文件直接渲染成一目录 PNG 帧(frame_0000.png就是 header),还支持--gif导出成循环播放的动图;qrx1_decode.py反向重组。生成的帧与网页版逐字节兼容。 - 测试:JS 和 Python 两侧对着同一份 golden fixture 断言(已知输入 → 已知 CRC 和 header),任何一侧偏离规范测试即失败。Python 侧
python -m pytest tests/test_qrx1.py零依赖可跑;JS 侧node --test直接从shadowcat.html里抽取协议函数在 vm 中执行,不需要 npm install。
想读源码的话,shadowcat.html 里的 crc32、Base64 切片和接收端状态管理都在同一个文件里,配合 SPEC.md 对照着看半天就能读完。
适用边界
适合:
- 老旧设备之间传小文件,且网络、蓝牙全部不可用
- 隔离环境(air-gapped)下小数据的单向导出
- 作为教学样本,理解"分块 + 循环 + 幂等接收"这种无回程信道的朴素可靠传输
不适合:
- MB 级文件。0.83 KB/s 的量级意味着 10 MB 要三个多小时,且轮次越多累计漏帧风险越大
- 可靠性要求高的场景。CRC 只能告诉你坏了,不能帮你省下重扫的时间
- 网络可用时。localtunnel、
python3 -m http.server乃至一根数据线,效率都高出几个数量级
采用前还有两个现实问题要掂量。其一,项目在 2026 年 5 月 26 日之后就没有新提交了,5 月底那个小高峰基本是压缩功能和站点部署,之后近五个月零动静——协议本身已经自洽(SPEC 齐全、双端测试锁定),但别期待 issues 有人回。其二,仓库没有 LICENSE 文件,按默认版权规则你不能直接拿代码二次分发,学习协议没问题,想集成进自己的产品需要先联系作者。
总结
ShadowCat 用最朴素的工程思路回答了一个边界问题:没有网络、没有蓝牙,只剩屏幕和摄像头,那就让屏幕发、摄像头收。它真正的设计含量在克制二字上——单向通道不做确认,可靠性交给无限循环;前向兼容不靠版本协商,靠未知标记即拒收;解析规则简化到 split('|')。这份 305 行的 SPEC 和两侧 golden fixture 测试加起来,比很多大型项目的协议文档更完整。如果你需要一个"最后手段"的传输通道,它现在就能用;如果你想学怎么把一个传输协议写到无法误用,这 190 KB 值得通读。
参与讨论
使用 GitHub 登录。欢迎补充事实、异议与实践。
讨论暂时无法加载。