隔空贴

隔空贴的技术方案

Windows 客户端技术方案

状态:已实现,随 1.2.0 发布(2026-10-09)。实机验证记录见 windows-validation-2026-10-09.md(安装、同步、生命周期)与 windows-features-validation-2026-10-09.md(托盘、配对弹窗、图片预览)

目标版本:1.2.0(Mac 与 Windows 同时发布)

相关:多出口网络的 NAT 打洞

一、目标

让 Windows 电脑加入隔空贴:与 Mac、与其他 Windows 电脑互相同步文本、带格式文本、图片和文件,体验与 Mac 版一致。

一期不做:

不做原因何时
ARM64 原生包Flutter 3.47 才正式支持 Windows ARM64,且只能在 ARM 机器上构建;x64 版在 ARM 上可以模拟运行二期,用 GitHub 的 ARM Windows 构建机
虚拟文件(Outlook 附件、压缩包里直接复制的文件)剪贴板里没有真实路径,只有流,需要实现 COM 接口视需求
自动更新、微软商店先跑通分发与签名二期

支持的系统:Windows 10 与 Windows 11,x64,Windows 10 不限版本。Flutter 引擎(flutter_windows.dll)与 Go 1.26 都要求 Windows 10;引擎直接引用的系统函数 Windows 8 就有,按 DPI 缩放这类新接口是运行时查找、找不到就退回。Windows 7、8 装不上。

二、现状:哪些能直接用

后台服务除剪贴板外全部是纯 Go:数据库用的是纯 Go 的 modernc.org/sqlite,压缩是纯 Go 的 zstd,cgo 只出现在 internal/clipboard/darwin.go。实测在 Mac 上已经可以直接交叉编译出 Windows 版:

CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build ./cmd/...    # pasteferryd.exe、pasteferry-cli.exe
CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build ./cmd/pasteferryd
模块Windows 上说明
信令、配对、签名(transport/signaling、peers、identity)直接复用
P2P、出口探测、打洞、中转(transport/p2p)直接复用防火墙与虚拟网卡见第七节
同步引擎、补发、存储、缓存、回收(sync、store、cache、gc)直接复用
本机接口(rpc,127.0.0.1 + 一次性 token)直接复用
数据目录(config.DefaultPaths)已有 Windows 分支%LOCALAPPDATA%\PasteFerry
打包解包(pack)要改文件名、符号链接,见第五节
剪贴板(clipboard)要新写目前是返回「不支持」的占位实现,见第四节
进程生命周期(cmd/pasteferryd)要改自启、守护、日志、单实例、注销,见第六节
界面(ui/)要改已有 Flutter 的 windows/ 工程骨架;后台服务管理、窗口外观、文案,见第八节
构建与发布(scripts/、CI)要新写Flutter 的 Windows 版只能在 Windows 上构建,见第十节

三、总体架构

与 Mac 版同构,三个可执行文件:

PasteFerry.exe(Flutter 界面)
   │  gRPC,127.0.0.1 随机端口 + token(%LOCALAPPDATA%\PasteFerry\daemon.json)
   ▼
pasteferryd.exe(Go 后台服务,用户会话内运行,无控制台窗口)
   ├─ 剪贴板:隐藏的消息窗口 + Win32 剪贴板 API
   ├─ 同步引擎 / 存储 / 缓存
   └─ 信令(WebSocket)+ P2P(WebRTC)──► 信令服务器 / 对端设备

pasteferry-cli.exe(命令行,排障用)

后台服务必须作为登录用户的普通进程运行,不能做成 Windows 服务。Windows 服务运行在会话 0,与用户桌面隔离,访问不到用户的剪贴板。这与 Mac 上用 LaunchAgent、不用 LaunchDaemon 是同一个原因。

四、剪贴板

这是一期的主要工作量。用纯 Go 实现(golang.org/x/sys/windows 加按需加载的 user32、kernel32、shell32 函数),不用 cgo,这样仍能在 Mac 上交叉编译。

1. 线程与消息循环

现有的 MainLoop 设计可以原样沿用,只是「事件循环」换成 Windows 的消息泵:

2. 格式对照

类型读取(优先级从高到低)写入
文件CF_HDROP(DragQueryFileW 取路径)CF_HDROP(DROPFILES 结构 + 双 NUL 结尾的 UTF-16 路径表),加上 Preferred DropEffect = DROPEFFECT_COPY,资源管理器粘贴时执行复制而不是移动
图片注册格式 PNG(Chrome、Office、截图工具都提供)→ CF_DIBV5 → CF_DIB,后两者转成 PNGPNG + CF_DIBV5(带透明通道);CF_DIB、CF_BITMAP 由系统自动合成
带格式文本注册格式 HTML Format(CF_HTML),解析头部偏移量取出 HTMLHTML Format(生成带偏移量的头部)+ CF_UNICODETEXT
纯文本CF_UNICODETEXTCF_UNICODETEXT(CF_TEXT 由系统合成)

判型顺序:文件最优先(资源管理器复制文件时剪贴板里同时有文件名文本);只有图片、没有文本时才算图片(截图、画图、浏览器里「复制图片」);Excel、Word 复制的内容同时带着一份渲染出来的图片,但有文本,按带格式文本处理。

文件粘贴不需要 COM。占位实现的注释里写着「文件粘贴需要在 Go 里实现 COM IDataObject」,那是按延迟渲染设想的。Mac 版已经验证过延迟渲染行不通,改成了「先落盘、再写剪贴板」;Windows 也一样,收到的文件已在本地缓存里,直接给 CF_HDROP 真实路径即可。

3. 不同步敏感内容

密码管理器复制密码时,会在剪贴板里放一个标记,告诉剪贴板工具「别记录、别同步」。Windows 上的约定(微软文档「Cloud Clipboard and Clipboard History Formats」,以及老的通行做法):

标记规则谁在用
ExcludeClipboardContentFromMonitorProcessing存在即跳过KeePass、KeePassXC
Clipboard Viewer Ignore存在即跳过KeePass 等较老的工具
CanIncludeInClipboardHistory值为 DWORD 0 时跳过(只看存在与否不够)Bitwarden 只写这一个
CanUploadToCloudClipboard值为 0 时跳过:它的含义正是「不要同步到别的设备」

命中就既不同步,也不写进复制记录。Mac 版目前没有做这件事(应检查 org.nspasteboard.ConcealedType 和 org.nspasteboard.TransientType),1Password、Bitwarden 复制的密码会被同步过去并留在记录里。这一条应先在 Mac 版单独修掉,不等 Windows。

4. 其他细节

五、跨平台内容兼容

网络上传输的格式保持现状(与 Mac 版一致),差异都在 Windows 一侧的剪贴板层与解包时消化:

项目网络上的约定Windows 侧的处理
文本换行LF读取时把 CRLF 统一成 LF;写入时转成 CRLF(记事本等程序依赖它)
带格式文本完整的 HTML 文本读取时去掉 CF_HTML 头部;写入时生成头部,用 <!--StartFragment--> 标出片段
图片PNGDIB(倒序扫描行、BI_BITFIELDS、32 位透明通道)与 PNG 互转
条目类型(ClipItem.content_type)沿用 Mac 的 UTType 字符串,如 public.pngWindows 侧按扩展名映射成同样的字符串,不另起一套
文件与文件夹tar + zstd解包时按下表处理文件名

文件名是最容易出事的地方。Mac 上合法的文件名在 Windows 上未必合法,处理不好轻则解包失败,重则有安全问题:

情况例子处理
Windows 不允许的字符 `: * ? " < > \\`会议:纪要.txt换成对应的全角字符(:*?"<>|\),名字仍然可读。冒号还关系到安全:a.txt:stream 会写进 NTFS 的备用数据流
结尾是点或空格notes.末尾加 _
设备保留名CON、nul.txt、COM1前面加 _
只差大小写Readme.md 与 README.md第二个改名为 README (2).md,不能互相覆盖
分解形式的 UnicodeMac 上的 が 可能是 か + 濁点两个码位统一成组合形式(NFC),否则资源管理器里显示成两个字符、搜索也搜不到。中文不受影响
路径超过 260 个字符深层文件夹Go 的 os 包会自动加 \\?\ 前缀,读写没问题;只是资源管理器对超长路径支持不好,记一条已知限制
符号链接文件夹里的软链接普通用户没有创建符号链接的权限(除非开启了开发者模式)。改为跳过并记录,不能让整次解包失败
系统垃圾文件.DS_Store、Thumbs.db、desktop.ini打包时跳过(Mac 版同步修改)

pack.safeJoin 的防目录穿越检查保留,在清洗文件名之后做;Windows 上额外拒绝驱动器号开头(C:foo)和 \\ 开头的路径。

六、后台服务的生命周期

需求Mac 版的做法Windows 版的做法
开机自启LaunchAgentHKCU\Software\Microsoft\Windows\CurrentVersion\Run 下写一项。只影响当前用户,不需要管理员权限
崩溃自动拉起launchd KeepAlive,10 秒节流pasteferryd.exe 自带守护模式:父进程拉起子进程,子进程异常退出就 10 秒后重启
只运行一个launchd 保证命名互斥量 Local\PasteFerry.Daemon,第二个实例直接退出
不弹黑框本来就没有以 GUI 子系统编译(-ldflags -H=windowsgui)
日志launchd 把标准输出重定向到文件自己写 %LOCALAPPDATA%\PasteFerry\logs\daemon.log,按大小轮转(Mac 版的日志目前不轮转,一并加上)
注销、关机SIGTERM隐藏窗口处理 WM_QUERYENDSESSION/WM_ENDSESSION,正常收尾
睡眠唤醒靠重连巡检处理 WM_POWERBROADCAST:唤醒后立即重新探测出口、重连,不等巡检
升级打开新版 App 时校正登录项并重启服务安装程序借助「重启管理器」关掉旧进程,装完再启动;界面同样校验版本、必要时重启服务

也评估过「计划任务」:它自带失败重启,但创建「登录时运行」的任务通常要管理员权限,与「不需要管理员即可安装」的目标冲突,所以不用。

七、网络

打洞、多出口探测、中转、补发的代码原样复用。Windows 上要注意三件事:

  1. 防火墙弹窗。后台服务在 0.0.0.0 上收发 UDP,Windows 防火墙第一次会弹窗询问是否允许。允许最好;即使用户拒绝,防火墙对 UDP 也是有状态的,本端先往对端发过包,对端的回包就能进来。打洞时双方本来就会互相发包,所以预计仍能直连,需要实测确认。安装程序不加防火墙规则,因为那需要管理员权限。
  2. 虚拟网卡。装了 WSL、Hyper-V、Docker 或 VPN 的电脑有很多虚拟网卡,其中 WSL 的网卡地址每次开机都会变。出口历史是按「本机各网卡的地址」区分网络的(localNetworkKey),会因此失效。Windows 上计算时排除名字以 vEthernet 开头的网卡和已知的 VPN 虚拟网卡。
  3. 「公用网络」。连上咖啡馆等网络时,Windows 默认把它设为「公用网络」,防火墙更严。行为同第 1 条,实测时覆盖。

八、界面

继续用 Flutter,一套代码。Flutter 的 windows/ 工程骨架已经在仓库里,daemon_client.dart 也已有 Windows 的数据目录分支。

1. 外观:像 Windows 11 自带的应用

Mac 版刻意做成系统自带应用的样子(石墨 + 系统蓝、透明标题栏、侧边栏延伸到红绿灯下)。Windows 版沿用同一个原则,参照 Windows 11 的「设置」应用:

MacWindows
窗口透明标题栏,红绿灯在左上自绘标题栏,最小化、最大化、关闭在右上;侧边栏顶部放应用名,与「设置」应用一致
背景系统材质Mica 材质(在 windows/runner 里调用 DwmSetWindowAttribute,不引入插件)
字体SF Pro、苹方(跟随系统)Segoe UI Variable、微软雅黑 UI(跟随系统)
图标CupertinoFluent 图标
强调色系统蓝跟随系统强调色,取不到时用同一个蓝

实现上在 theme.dart 加一层平台差异(字体、图标、标题栏高度、圆角),页面结构与交互不变:复制记录、设备、设置三页。

2. 后台服务管理

background_service.dart 目前全是 launchd 的逻辑,抽成接口,分 Mac 与 Windows 两个实现。Windows 的状态判定:

状态判定
未安装Run 下没有隔空贴项
指向旧位置Run 项的路径不是当前这份 pasteferryd.exe
已停止daemon.json 不存在,或其中的进程号已不在运行
运行中能连上,且版本与界面一致(不一致就重启服务)

3. 文案与小处

九、安全

项目做法
设备私钥(identity.json)用 DPAPI(CryptProtectData,当前用户范围)加密后落盘。别的用户、拷走文件到别的电脑都解不开。Mac 版目前是权限 0600 的明文文件,后续可迁到钥匙串
数据目录%LOCALAPPDATA% 本身只有当前用户可访问,不另设权限
本机接口与 Mac 相同:只监听 127.0.0.1,必须带 token
密码等敏感内容见第四节第 3 条:命中标记就不同步、不记录
收到的文件一期不加「来自网络」标记(MOTW):内容来自自己的设备,加了会让 Office 以受保护视图打开、可执行文件弹警告。二期考虑「源文件带标记才带过去」

十、安装、签名与发布

1. 安装包

用 Inno Setup 做按用户安装的安装包(类似 VS Code 的「用户安装版」):

也评估过 MSIX:好处是安装干净、能自动更新;但要求必须有受信任的签名证书,且应用写入 %LOCALAPPDATA% 的数据会被重定向到包的私有目录,命令行工具与后台服务对不上路径。一期不用。

2. 代码签名

不签名的安装包在下载、运行时会被 SmartScreen 拦一下(「Windows 已保护你的电脑」→「更多信息」→「仍要运行」),与 Mac 版没经过公证时「仍要打开」是一回事。可选的路子:

方案费用效果
不签名(一期)0每个新版本都被 SmartScreen 拦,README 里写清楚怎么放行
普通代码签名证书(OV)每年数百美元起有发布者名称;SmartScreen 信誉要随下载量慢慢积累
微软的云签名服务按月计费价格低,但对个人开发者有地区限制,需要确认是否可用

3. 构建与发布

Flutter 的 Windows 版只能在 Windows 上构建,现有的 scripts/package.sh 跑在 Mac 上,所以 Windows 包由 CI 构建:

十一、测试与验证

  1. 纯函数单测(任何系统上都能跑):CF_HTML 头部的生成与解析、DIB 与 PNG 互转(含倒序扫描行、透明通道)、DROPFILES 编解码、换行转换、文件名清洗(第五节每一行一个用例)、敏感标记判定。
  2. Windows 上的集成测试:真实剪贴板的读写往返(文本、HTML、图片、文件)、跳过本机写入、密码管理器标记。GitHub 的 Windows 构建机能否访问剪贴板需要先验证;不行的话这部分在本地 Windows 机器上跑。
  3. Mac ↔ Windows 联调清单:
    • 内容:中英文文本;从网页、Word、Excel 复制的带格式文本;截图、带透明背景的 PNG;单个文件、多个文件、深层文件夹、中文与日文文件名、含 : 的文件名;1 GB 大文件按需拉取。
    • 粘贴目标:资源管理器、微信、Office、记事本。
    • 网络:同一局域网、公司多出口网络、家用宽带、防火墙弹窗点「取消」、睡眠唤醒、断网恢复。
    • 生命周期:注销与重启、升级安装、卸载后重装。
  4. NAT 打洞实验室:网络代码与平台无关,现有 11 个场景继续覆盖。

十二、里程碑

按一个人全职估算,约 6 周:

阶段内容产出估时
M1剪贴板实现(第四节)与后台服务生命周期(第六节)pasteferryd.exe 在 Windows 上跑通:用 pasteferry-cli 能与 Mac 配对、互相同步文本与图片2 周
M2文件与内容兼容(第五节)、敏感内容过滤联调清单里的内容类全部通过1 周
M3界面适配(第八节)Windows 外观的界面,能启用、管理后台服务1.5 周
M4安装包、CI、文档与网站Release 里出现 Windows 安装包,README 与网站补上 Windows1 周
M5实机联调与修复,发布 1.2.00.5 周起

M1 结束时就能验证最大的不确定点:剪贴板方案、防火墙行为、Windows 上的打洞。之后的阶段风险较小。

十三、需要拍板的事

  1. 代码签名:一期不签名(推荐,先验证需求),还是一开始就买证书?
  2. 托盘图标:已按用户需求加入一期,由后台服务持有,随登录自启、关闭主窗口后保留;退出时停止当前后台同步,但保留下次登录的自启设置。
  3. ARM64:一期只出 x64(推荐,ARM 机器可以模拟运行),还是同时出 ARM64?
  4. Mac 版的敏感内容漏洞(第四节第 3 条):建议马上单独修,发 1.1.3。

附:要改动的代码

位置改动
client-core/internal/clipboard/windows.go(新)Win32 剪贴板实现、消息窗口、格式转换
client-core/internal/clipboard/other.go构建标签改为排除 darwin 与 windows
client-core/internal/clipboard/sensitive.go(新)敏感内容判定(两个平台共用接口)
client-core/internal/pack/pack.go文件名清洗、符号链接、垃圾文件过滤
client-core/internal/transport/p2p/egress_history.go网络标识排除虚拟网卡
client-core/internal/identity/identity.goWindows 上用 DPAPI 保护私钥
client-core/cmd/pasteferryd/main.go守护模式、单实例、日志文件、会话结束与电源事件
ui/lib/background_service.dart拆成接口与 Mac、Windows 两个实现
ui/lib/theme.dart、ui/lib/main.dart平台外观、自绘标题栏
ui/windows/runner/Mica 背景、窗口尺寸记忆、应用图标
scripts/、.github/workflows/Windows 构建、安装包、发布
tools/installer/(新)Inno Setup 脚本