# Browser Link 开发记:🍉 西瓜备份是怎么用浏览器把 iPhone 照片直接备份到电脑

2 阅读11分钟

🍉 西瓜备份 (英文名是 Watermelon Backup)是我做的一款开源照片备份 App,而 Browser Link 是它这两天刚加入的连接方式,在 App 里的名字更直白,就叫“备份到电脑”。电脑打开网页、选一个文件夹,再用 iPhone 扫码,照片就会通过局域网写进这个文件夹。

这个需求听起来很简单

做西瓜备份之后,经常有人问我:能不能直接把 iPhone 里的照片备份到电脑硬盘?

西瓜备份已经支持 SMB、WebDAV、S3、SFTP 和外接存储。照理说,在电脑上开一个 SMB 共享就行了。

可多数用户连 SMB 是什么都不知道。创建账号、设置共享目录、放行防火墙,每一步都有可能卡住。用户只是想找个地方放照片,我却先发给他一篇文件服务器配置教程,确实有点强人所难。

更何况还有苹果文件夹共享这种我根本连不上的 SMB(这个问题排查过程真的可以写一篇文章了),有些用户好不容易架起来用不了的,感觉被骂的还是我。

接一根线看起来最符合直觉。我最早也顺着这个方向想过:分别写 Windows 和 macOS 客户端,再解决手机与桌面端的通信。真动手的话,安装包、签名、升级、系统权限和两套平台代码都会跟着来,通信部分还可能碰到私有 API 的不确定性。既然是私有 API,上架苹果是不可能,放在 App 里,让用户拖出来吗?放个官网?再开几个Repo?桌面客户端既然都写了,顺手设计一套西瓜备份专用协议当然也可以?有必要吗?

后来我想起以前接触视频会议时常看到的一句话:WebRTC 在局域网里可以直接传输,不必把流量绕到公网。

这几年浏览器也多了 File System Access API。用户主动选择目录后,网页可以在授权范围内读写本地文件。

两块拼起来,思路一下就顺了:让浏览器临时扮演电脑端,WebRTC 负责把手机和这个网页连起来。这样既有桌面端的文件访问能力,也省掉了软件安装。

一次连接是怎么建立的

Browser Link 的链路不复杂:

iPhone 上的西瓜备份
        │
        │  WebRTC DataChannel
        ▼
电脑浏览器 ── File System Access API ── 用户选择的文件夹

        两端在建连时经过信令服务交换少量信息

用户打开 link.watermelonbackup.com,选择备份目录,网页显示二维码。西瓜备份扫码后,两端通过信令服务交换 WebRTC 建连信息。DataChannel 连通,照片和文件操作便开始在局域网里传输。系统相机和 App 内置扫码器都可以进入这条流程。

这里没有 STUN 和 TURN,连接范围就放在同一局域网。某些复杂网络环境可能连不上,这是我接受的一项兼容性取舍。

信令服务的工作到建连为止。它不接收照片,也不保存备份内容。配对使用临时信息,相关信令经过加密。

Browser Link 前端使用 TypeScript 和 Vite,处理目录授权、二维码、连接状态以及浏览器端的文件操作。后端是一个很小的 Node.js 服务,通过 WebSocket 转发信令,会话状态也不会写进磁盘。

我挺喜欢这个分工。后端保持轻量,照片留在手机、局域网和用户硬盘这条路径里。

怎么接进原来的备份系统

让网页收到一张照片并写进文件夹,很快就能做出 Demo。接进西瓜备份现有的备份流程,花的时间要多得多。

西瓜备份传输照片时还要维护资源哈希、月度 manifest、远端索引、去重、写锁和中断恢复。Browser Link 如果另起一套备份流程,这些逻辑都得重写一遍,以后改功能也要维护两份。

最后我给它做了一个新的存储客户端:

actor BrowserLinkStorageClient: RemoteStorageClientProtocol

西瓜备份里的 SMB、WebDAV、S3、SFTP 都实现了同一个 RemoteStorageClientProtocol。Browser Link 接上这层之后,备份管线看到的仍然是一个存储节点。列目录、读取元数据、创建目录、移动、复制、上传和下载等操作,经由 DataChannel 发给浏览器,再由浏览器调用 File System Access API 完成。连接期间也可以浏览和下载电脑端已有的备份。

这种接法省下了大量重复代码。原有的索引、去重和恢复逻辑可以直接使用,新增的内容主要集中在连接与传输层。

Browser Link 节点不会存进 App。网页关闭或者连接断开,它便从当前会话里消失,也不会被后台计划任务唤醒。当前备份已经开始并启用了 PiP 时,传输可以跟着现有的 PiP 机制继续一段时间。它更接近一块临时插上的网络硬盘。

做到这里,我才确定这个方向值得继续。浏览器在西瓜备份里拥有完整的存储接口,已经超出了普通网页上传框的用法。

传输协议改过一次大的

第一版协议很朴素。文件按 32 KiB 切块,转成 Base64 放进 JSON;浏览器每写完一块,手机再发下一块。

这版很好调试,控制台里能直接看到所有请求。实际传大视频时,缺点也很明显。Base64 增加了数据量,每个分块都要来回等待,吞吐始终上不去。

还有个容易忽略的点:DataChannel.send() 成功,只能说明数据进了发送队列。此时把上传标记成完成,浏览器那边可能还没写完硬盘。

后来我把控制消息和文件内容拆开。开始、结束、取消、确认继续走 JSON,照片内容改用二进制 frame。浏览器完成写入以后才返回累计 ACK,手机则根据未确认的数据量控制发送速度。

每次连接只创建一个 PeerConnection 和一条 DataChannel。多个文件通过逻辑流交错传输,没必要为了并发再建好几套 WebRTC 连接。Browser Link 默认跑两个备份 worker,用户可以调到四个。

备份时会并行处理多项资源,写锁续期、取消和清理消息也要及时送达。传输窗口因此给这些小消息留了余量,避免大文件把通道全部塞满。

这次调整以后,DataChannel 才能稳定承载西瓜备份原来的并行备份流程。

架构图画完,真机问题才刚开始

最早的原型在信令对端加入后就显示“已连接”。有一次手机还在用 5G,页面已经进入连接状态,App 却没有可用的备份节点。我当时才把几个阶段认真拆开:DataChannel 建立、两端握手、浏览器目录就绪、临时客户端注册、远端索引加载。全部走完,首页才显示连接成功。

浏览器兼容也踩过坑。同一网络里 Edge 可以连接,Chrome 一直停在 checking,最后查到是 Chrome 的 Local Network 权限被关了。网页后来把能力与局域网权限检查放到第一步,文案也从“同一 Wi-Fi”改成“同一局域网”。电脑接网线、手机连 Wi-Fi 完全可以工作,iPhone 通过 USB-C 网卡接入时也不该被误判。

并发测试有一次更吓人。我开四个 worker 同时备份四个月,最后两个 SQLite manifest 只有表结构,没有记录。问题出在多个 worker 同时准备共享父目录:一个 worker 已经创建成功,另一个却把“目录已存在”当成冲突,后续 manifest 没能提交。目录创建改成幂等以后,又补了一组四个月并发回归测试。

还有一次,Browser Link 只要切到后台就会立刻断开。最后查到 App 原来的生命周期代码会在 sceneDidEnterBackground 主动停止任务,刚好和 PiP 的后台承接逻辑打架。去掉这条主动终止链路后,已经运行的备份可以在 PiP 生效时继续传输。这个修复也让我重新检查了暂停和取消:停止一个文件任务只清理对应传输,不能顺手销毁整条 Link。

临时节点留下的写锁怎么办

西瓜备份会在备份目录里创建写锁,避免两个设备同时改同一个仓库。普通网络节点可以随时重连,Browser Link 的网页关掉后,刚才那个节点就彻底不存在了。

例如用户主动断开后马上重新扫码,新连接可能看到上一轮留下的有效写锁。直接接着用缺少依据,老老实实等锁过期又影响正常使用。

现在每次 Link 都有一个临时节点 ID。页面正常结束时,会停止接收新任务,等已经排队的文件操作收尾,再记录这次连接已经安全关闭。下一次扫码时,App 可以利用这份记录处理上一轮的锁。

浏览器崩溃时通常来不及完成收尾。遇到这种情况就按原来的超时规则等待,速度慢一些,仓库状态更可控。

浏览器还可以用 Web Locks 协调同一网站的多个标签页。不过它管不到 Finder 和电脑上的其他进程,因此页面仍会提醒用户:备份期间不要手动修改所选文件夹。

文件日期没法完整保留

Browser Link 有一个绕不开的取舍。File System Access API 可以创建和写入文件,却没有设置原始文件日期的接口。

协议多传一个时间字段也帮不上忙。浏览器落盘的文件会使用写入时的日期。很在意文件系统时间的用户,使用 SMB、SFTP、外接存储或桌面客户端会更合适。

西瓜备份会在备份目录附带一份说明,以及 Windows 和 macOS 脚本。备份结束后手动运行,脚本可以读取 manifest,按照片的创建时间重新设置文件修改时间。

我把脚本视作可选的事后整理。它不会自动执行,也还原不了源文件系统里的全部时间元数据。用户没有运行脚本,文件日期就是浏览器写入当天。免安装带来的便利,在这里付出了一点代价。

WebRTC 给安装包添了十兆

Browser Link 在 iOS 上用到 PeerConnection 和 DataChannel,Google WebRTC SDK 里则包含了音视频采集、编解码器、渲染和 RTP 等大量代码。

完整版本放进 App 后,可执行文件增加了约 10.2 MB。西瓜备份本身体积不大,这十兆相当显眼。

后来我单独做了 WatermelonWebRTC。它固定官方 WebRTC 源码版本,沿用 Google 的构建工具,裁掉 Browser Link 用不到的音视频功能。

关闭编解码器、音频处理、渲染和日志模块,再处理 Objective-C 依赖与链接范围,可执行文件降到了 3,385,488 字节。这个结果已经让我挺满意了,我还是顺口问了 Codex 一句:“还能不能再小?”

它接着检查编译和链接配置,试了 size profile、-Oz、ThinLTO,也继续排查没有使用的 helper。最终 device arm64 可执行文件是 2,718,756 字节。

当然也走过弯路。ThinLTO O2 会把文件变大;有个 Objective-C helper 删除后可以正常编译和链接,运行时才发现缺少 NSString 转换 category;另一些方案只能省几十 KB,代价却不合算,我也没有采用。

最终发布的运行时 XCFramework 压缩包从约 16 MB 降到 3.9 MB,device 可执行文件缩小约 73%。源码版本、补丁哈希、构建方式和 SwiftPM checksum 都记录在仓库 README 里。

这一段和 Codex 的合作比我预想中有趣。它给出下一步值得试的方向,我负责构建、看体积、跑真机,再决定保留哪一项。优化最后能留下来,靠的是一轮轮真实产物和运行结果。

做完以后再看这个构想

WebRTC 和 File System Access API 都是现成技术,我做的事情是把它们接到西瓜备份原有的存储层上。电脑免装客户端,App 也能沿用已经跑了很久的备份管线。

网页拿目录授权,WebRTC 走局域网,后端协助建连,BrowserLinkStorageClient 把文件操作交给浏览器。写锁和 ACK 处理备份期间的状态,文件日期则成了这套方案留下的遗憾。

用户最后看到的流程依旧只有几步:打开网页、选择文件夹、扫码、开始备份。

从经常有人问“能不能直接备份到电脑”,到真的做出 Browser Link,中间绕了不少路。现在看来,没写 Windows 和 macOS 客户端反倒促成了一个更轻的方案。

如果你对西瓜备份的实现或者 WebRTC 体积优化感兴趣,欢迎到相应的 GitHub 项目交流。