用 Go 和 Wails 实现离线文件传输:offline-lan-transfer 的设计与实践

用 Go 和 Wails 实现离线文件传输:offline-lan-transfer 的设计与实践

两台电脑在同一个局域网里,想传一个文件,听起来应该很简单:

1
选择文件 → 发给另一台电脑 → 保存

但如果加上几个条件,事情就没有那么简单了:

  • 不登录账号,不上传云盘;
  • 互联网断开时仍然可以使用;
  • 不自动连接公共中转服务;
  • 接收前能够看到文件清单,并由用户决定是否接受;
  • 传输过程中能查看进度,也能真正取消;
  • 不能因为同名文件、异常中断或路径问题破坏已有数据。

围绕这些要求,我实现了一个局域网文件传输桌面项目:offline-lan-transfer

项目使用 Go、Wails v2 和 Vue 3 构建桌面界面,底层复用 croc 的传输能力,通过一个范围受控的维护分支补齐桌面集成需要的接口。

先说明项目定位:

offline-lan-transfer 是基于 croc 的独立衍生项目,不是 croc 官方版本。当前完成的是本地 MVP 实现与开发环境验证,不代表已经通过 Windows 真机、安装包和发布验收。

本文既记录实现过程中的关键取舍,也给出构建和实际使用步骤。


一、先明确“离线传输”要解决什么

1.1 不只是传输时没有打开浏览器

一个工具看起来在局域网传文件,并不代表它不会访问互联网。

外部请求可能发生在用户点击发送之前,也可能出现在失败之后:

1
2
3
4
5
6
7
8
9
进程启动

依赖初始化时解析默认域名

用户指定的 Relay 连接失败

尝试其他连接地址

界面或运行时加载远程资源

因此,这个项目把离线约束放在多个层次实现:

  • 前端资源本地构建,不使用 CDN、远程字体或远程图标;
  • 传输必须使用用户明确配置的局域网 Relay;
  • 禁止官方公共 Relay 和公网地址;
  • 不做自动发现、遥测或在线更新;
  • hostname 只通过本机 hosts 文件解析,不调用 DNS 或 mDNS;
  • WebView2 缺失时提示用户准备离线运行时,不自动下载。

这里的“离线”指的是运行时不依赖互联网,不是不需要网络。两台电脑仍然需要保持局域网互通。

首次准备编译工具、Go 模块、npm 包和 WebView2,也可以在联网准备环境完成,再带到离线环境使用。这与应用运行时的网络边界是两件事。

1.2 第一版刻意控制范围

当前桌面目标是 Windows 10/11 x64。第一版只允许一个活动传输任务,先把下面这条流程做完整:

1
2
3
4
5
6
7
8
9
10
11
12
13
配置 Relay

选择文件并生成传输短语

接收方输入短语

展示清单并人工确认

传输、进度与取消

检查临时文件并提升到最终目录

记录非敏感历史

扫码导入、并行多任务、自动更新和 Linux 桌面版不属于当前已实现功能。


二、为什么选择 croc,而不是重新实现传输协议

文件传输界面看起来不复杂,但底层涉及连接、握手、加密、文件信息交换、数据流和异常退出。

本项目选择复用 croc 已有的传输机制,把主要精力放在桌面产品需要的控制能力上。

2.1 没有通过命令行子进程包装

一个直观方案是从 GUI 启动 croc CLI,再解析终端输出:

1
GUI → 启动命令行 → 读取 stderr → 解析进度条

这种方式容易遇到几个问题:

  • 进度文本不是稳定的结构化接口;
  • 接收确认依赖终端输入,GUI 很难安全接管;
  • 取消子进程与正确释放传输资源不是同一个问题;
  • 传输短语或密码可能出现在命令参数、说明文本和日志中。

因此,正式集成使用 Go API,不启动 croc CLI,也不把解析 stderr 当作进度方案。

2.2 维护分支只处理集成边界

当前 croc 上游基线固定为:

1
2
tag:    v11.0.2
commit: b04883faadaae069800e3ccecc6da4af0f1792fc

项目使用的维护分支 revision 为:

1
dcc584b6e01a07f0ead0d61393732d0ab792da41

补丁集中在以下方面:

集成问题 维护分支的处理
默认 Relay 解析发生得过早 改为惰性解析
发送说明输出短语、密码和链接 增加 SuppressSendInstructions
debug 日志包含认证或协议敏感数据 清理敏感日志
接收确认依赖 stdin 增加 ManifestApprover
进度不适合直接供 GUI 使用 增加结构化 EventSink
取消后仍可能阻塞 补齐 context 与资源生命周期处理

PAKE、加密算法、wire message format 和 Relay protocol 保持原有行为。staging、冲突策略、数据库和 GUI 则留在主项目中。


三、整体架构:让界面与传输引擎分开

项目的依赖方向如下:

1
2
3
4
5
6
7
8
9
10
11
Vue 3 UI

Wails bindings

Application Service

TransferEngine

croc adapter

固定 revision 的 croc fork

产品能力由主项目中的模块承接:

1
2
3
4
5
6
7
8
9
10
11
12
internal/
├── application/ 任务编排、界面 DTO、错误与历史
├── transfer/ 项目自己的传输接口与事件类型
├── transferengine/ 接收安全流程与终态协调
├── crocadapter/ croc API 适配
├── filesafety/ 路径、冲突、staging 与最终提升
├── relay/ Relay 配置与校验
├── storage/ SQLite 与 migration
├── secretstore/ 密码存储抽象与 Windows DPAPI
├── network/ 局域网地址与本地解析策略
├── logging/ 白名单结构化日志
└── diagnostics/ 安全诊断导出

例如,项目自己的传输接口是:

1
2
3
4
5
6
7
type Engine interface {
Send(context.Context, SendRequest, EventSink) (Result, error)
Receive(context.Context, ReceiveRequest, EventSink) (Result, error)
StartRelay(context.Context, RelayConfig) error
StopRelay(context.Context) error
ProbeRelay(context.Context, RelayConfig) (RelayProbeResult, error)
}

界面处理的是项目 DTO,不需要知道 croc 内部如何表示文件、连接或协议消息。将来升级底层依赖时,适配层负责处理接口差异,而不必让整个前端跟随修改。


四、接收流程:先确认,再写入最终位置

4.1 “收到文件清单”不等于“已经同意接收”

GUI 接收需要一个明确的暂停点:拿到完整清单后,等待用户选择。

1
2
3
4
5
6
7
收到并验证 manifest

构造安全快照

显示数量、大小、路径摘要与保存位置

等待 Accept / Reject / Cancel

manifest 快照不能把正在修改的内部 slice 暴露给界面,等待确认也不能阻塞取消。

取消与接受同时到达时,也不能仅依赖一个普通 select 来碰运气。仲裁逻辑需要在等待前检查 context,并在取到接受结果后再次检查,只有最后检查仍未取消时才允许接受生效。这个检查点定义了接受与取消竞争时的判定边界。

4.2 为什么引入 staging

直接把网络数据写到最终文件名,有一个明显问题:

网络断开后,一个只写了一部分的文件,可能看起来已经是用户要的最终文件。

本项目让 croc 先写入保存目录内的独立临时目录:

1
2
3
用户保存目录/
└── .croc-staging-<随机标识>/
└── 本次收到的文件

成功路径为:

1
2
3
4
5
6
7
8
9
10
11
传输结束

检查路径、类型、大小和清单对应关系

再次处理目标冲突与安全检查

提升到最终路径

清理 staging

发布产品层完成状态

失败或取消默认清理临时目录;清理失败会返回错误。临时目录放在最终目录所在位置,也方便保持同一文件系统内的提升流程。

这里要区分两类验证:产品层当前独立检查路径、类型和大小;传输内容校验由固定的 croc 引擎负责。集成测试另外比较传输前后 SHA-256,但不能因此声称产品新增了一套独立 SHA-256 传输协议。

4.3 同名文件不能静默覆盖

策略 当前行为
AutoRename 默认保留已有文件,为传入文件选择新名称
Ask 发现冲突时停止,需要重新选择目录或策略
Skip 保留冲突目标,不用传入文件替换
Overwrite 需要用户明确勾选确认,仍受路径安全检查约束

第一版的 Ask 并不是逐文件弹窗决策。把这一点写清楚,比让用户根据名称猜测行为更重要。

路径校验还包括目录穿越、绝对路径逃逸、Windows 保留名称、非法字符和符号链接等情况。


五、进度、取消与页面切换

5.1 用事件驱动界面,而不是解析进度条

底层事件经过适配和应用层处理后传递给 Vue,覆盖阶段、清单、文件开始、进度、文件完成、重连和终态。

界面不轮询 croc 旧公开字段,也不解析终端进度条。

这里不仅要关注“有没有进度事件”,还要处理:

  • 事件是否乱序或重复;
  • 迟到的 progress 是否会覆盖 completed / canceled;
  • 任务启动方法返回前,manifest 是否已经先到达;
  • 慢 UI 是否会阻塞传输;
  • terminal 事件是否已经交付,任务就被销毁了。

项目用事件序号过滤过期更新,保留提前到达的 manifest,并在销毁任务前处理事件 drain。

5.2 Cancel 必须作用到实际资源

仅仅将界面文字改成“已取消”,并不能证明传输已经停止。

取消需要贯穿整个调用链:

1
2
3
4
5
6
7
8
9
10
11
用户点击 Cancel

Application Service 取消 context

传输层解除连接和等待

关闭文件与网络资源

处理 staging

返回 canceled 终态

任务取消时,界面先进入 cancelling,等待最后结果。第一版的发送和接收共享一个活动任务名额,避免同时操作多个传输带来的额外生命周期问题。

5.3 切换页面不应该丢失任务

发送和接收页面通过 Vue KeepAlive 保留实例,隐藏时继续处理事件。因此,切到设置页再回来,任务仍然可见;即使终态在页面隐藏期间到达,也能清理短语和二维码展示。

需要注意,清理应用中的引用不等于物理擦除 Go/JavaScript 内存,也不等于自动清空用户已经复制到系统剪贴板的内容。


六、严格离线与秘密保护中容易忽略的细节

6.1 自定义 Relay 不代表已经消除公网请求

除了默认 Relay 与 fallback,还必须检查 hostname 解析。

如果将 relay.lan 直接交给系统解析器,最终采用哪个 DNS 服务器仍由操作系统配置决定。域名看起来属于局域网,并不保证解析过程不会出网。

当前实现只接受局域网地址,并使用本机 hosts 映射解析名称:

1
2
3
4
明确的 LAN IP → 校验后使用
localhost → 本机 loopback
本地 hostname → 读取 hosts → 校验结果必须为 LAN 地址
其他情况 → 连接前失败

这是一个明确的易用性取舍:没有 DNS/mDNS 自动发现,用户需要输入局域网 IP,或者事先配置 hosts。

6.2 Relay 密码与传输短语不同

信息 用途 生命周期
Relay 密码 连接指定 Relay 客户端配置通过 SecretStore 保存;内嵌服务密码用于本次运行
传输短语 匹配并保护本次传输 必要的任务生命周期内使用,不写入历史和数据库

Windows 密码存储使用当前用户 DPAPI。SQLite 保存的是密码引用和非敏感元数据,而不是 Relay 明文密码。

传输 QR 携带本次短语,是敏感信息;Relay QR 默认只携带地址和端口,密码另行提供。当前界面提供二维码生成与展示,没有应用内扫码入口。

6.3 不把原始错误直接显示给用户

底层错误可能包含协议细节、路径或其他动态信息,因此应用层转换为稳定类别,例如连接不可达、认证失败、空间不足、路径不安全、冲突、校验失败和取消。

日志采用字段白名单,诊断 ZIP 只收集允许的版本、系统、schema、安全状态与日志信息。原始协议 payload、room、密钥、短语和密码不能作为“方便调试”的字段直接写进去。


七、如何构建可以运行的 Windows EXE

7.1 所有命令从仓库根目录开始

项目地址:

github.com/jianingdai/offline-lan-transfer

可以先在联网准备环境获取代码:

1
2
git clone https://github.com/jianingdai/offline-lan-transfer.git
cd offline-lan-transfer

这里的目录应当能看到 go.mod、wails.json 和 frontend,而不是进入 docs 后执行构建。

当前依赖要求包括:

  • go.mod 声明的 Go 工具链,目前为 Go 1.25.0;
  • 与锁定前端工具链兼容的 Node.js/npm,开发验证使用 Node.js 24;
  • Wails CLI v2.15.0;
  • 需要安装包时另行准备 NSIS。

7.2 准备依赖

以下步骤可能联网,应在准备环境执行:

1
2
3
4
5
6
go install github.com/wailsapp/wails/v2/cmd/wails@v2.15.0
go mod download

cd frontend
npm ci
cd ..

确认 wails 已在 PATH 中:

1
wails version

项目固定使用 v2.15.0,不要把不同版本的构建结果混为一谈。依赖缓存和工具需要为实际构建平台准备,Linux 上安装的二进制依赖不应直接当作 Windows 安装结果。

7.3 构建 EXE

在仓库根目录执行:

1
wails build -platform windows/amd64 -webview2 error

生成位置:

1
2
3
4
offline-lan-transfer/
└── build/
└── bin/
└── offline-lan-transfer.exe

该文件在 Windows 上运行,需要预先安装兼容的 WebView2 Runtime。应用不会在启动后自动下载运行时。

仓库也提供便携包脚本。在 Windows PowerShell、仓库根目录执行:

1
2
3
4
5
# 只验证打包配置
powershell -NoProfile -File build/windows/build.ps1 -ValidateOnly

# 构建便携版 ZIP
powershell -NoProfile -File build/windows/build.ps1

默认输出:

1
build/releases/offline-lan-transfer-0.1.0-windows-x64.zip

脚本要求依赖事先缓存,使用本地工具链、GOPROXY=off 和 npm 离线模式。已有输出不会被覆盖,需要先自行归档。完整 Wails/NSIS 打包尚未在当前开发环境验收,不能把配置检查成功当作安装包验证通过。

7.4 Linux 文件不等于 Linux 桌面客户端

当前没有实现 Linux 桌面版。Linux 可以编译无界面的 Relay 服务:

1
2
# 在 Linux 上,从仓库根目录执行
go build -o build/bin/offline-lan-relay ./cmd/offline-lan-relay

这是中转服务,不会打开文件传输窗口,也不能在没有配置时直接双击使用。Relay 运行需要绑定地址、端口和密码文件等配置;容器配置与离线部署步骤见仓库的 build/docker/README.md。


八、两台电脑实际如何使用

假设电脑 A 的局域网 IP 是 192.168.1.20,电脑 B 是接收端。下面的地址只用于示例,应替换成实际网卡地址。

8.1 在电脑 A 启动 Relay

进入“局域网中心”,在“启动本机局域网中心”中填写:

配置项 示例
绑定地址 192.168.1.20
控制端口 9009
数据端口 9010, 9011, 9012, 9013
临时 Relay 密码 自行生成并妥善传递的强密码

点击“启动 Relay”,确认处于运行中。两机传输不要绑定 127.0.0.1,它只能服务本机连接。

控制端口和所有数据端口都需要允许必要的 TCP 入站访问。应用提供防火墙检查和规则规划,不会替用户关闭防火墙或自动创建管理员规则。

8.2 两端都添加 Relay Profile

启动内嵌服务与保存客户端配置是两个操作。电脑 A 和电脑 B 都需要在“新增 Relay”中填写同一个服务地址、端口和密码,然后启用并测试连接。

即使 Relay 与发送端在同一个应用中,发送端也需要一个可选择的 Relay Profile。

8.3 发送端生成短语

  1. 进入“发送”;
  2. 添加单文件、多文件或文件夹;
  3. 选择启用的 Relay;
  4. 点击“生成短语并开始发送”;
  5. 复制短语,通过可信渠道交给接收方。

8.4 接收端查看清单并接受

  1. 进入“接收”,粘贴短语;
  2. 选择保存目录和同一个 Relay;
  3. 选择冲突策略,默认自动重命名;
  4. 点击“连接并查看清单”;
  5. 检查数量、大小、保存位置和空间;
  6. 点击“Accept 并接收”,或者点击“Reject”。

传输过程中可以取消。任务成功后查看最终目录,也可以从“传输记录”查看非敏感结果;清空历史不会删除收到的文件。


九、如何验证它,而不只验证“按钮能点”

9.1 功能测试与并发测试

仓库根目录:

1
2
go test ./...
go vet ./...

前端目录:

1
2
3
4
cd frontend
npm test
npm run typecheck
npm run build

实际覆盖包括 loopback Relay、发送接收、Accept/Reject、取消、目录与多文件、AutoRename、staging 清理和文件 SHA-256 一致性。

前端测试还覆盖乱序事件、终态保持、提前到达的 manifest,以及切换页面后的任务实例与订阅生命周期。

已知 croc 全局 logger race 仍然保留。项目自有并发测试通过,不代表整个依赖树的 race 检查全部通过。

9.2 网络审计要隔离 Go 工具链

直接审计 go test,可能把模块下载等构建行为也统计进去。这里采用先构建测试程序,再单独跟踪它的方式。

下面是 Linux/WSL 上可执行的一个审计入口,需要安装 strace:

1
2
3
4
5
6
7
8
9
10
# 在仓库根目录,依赖已缓存的前提下执行
GOTOOLCHAIN=local GOPROXY=off \
go test -c ./internal/crocadapter -o /tmp/olt-adapter.test

GOTOOLCHAIN=local GOPROXY=off \
strace -ff -qq -s 256 -e trace=network \
-o /tmp/olt-network \
/tmp/olt-adapter.test \
-test.run '^TestTransferEngineLoopbackBaseline$' \
-test.count=1

已记录的一次 localhost 传输审计中,12 次 connect 全部指向 loopback,未观察到 DNS 或公网连接。

这证明的是该测试路径的行为,不能直接扩展为“已经完成所有 Windows/WebView2 运行场景的流量审计”。

9.3 秘密扫描需要区分证据类型

项目测试分别检查:

  • 真实传输子进程输出中的测试短语、密码、派生 room 和 getcroc 链接;
  • argv 和环境变量中是否存在测试凭据;
  • SQLite、WAL/SHM、历史 DTO、日志和诊断 ZIP 中的敏感标记;
  • 固定 croc 分支中的握手与协议日志回归。

模拟错误里的 key sentinel 能验证应用过滤逻辑,但不等于抓到了真实 PAKE key 再完成精确扫描。区分这些证据,才能避免把测试范围写得比实际更大。


十、常见问题与排查顺序

现象 优先检查
启动提示缺少 WebView2 提前离线安装兼容运行时,或检查 EXE 旁固定版运行时目录
connection refused Relay 是否运行、控制端口和绑定地址是否正确
timeout 网卡、防火墙、设备互访与 Wi-Fi 客户端隔离
authentication failure 是否把传输短语误当成 Relay 密码,或保存了旧密码
hostname 无法连接 本机 hosts 是否有映射;可先改用 LAN IP
测试连接成功但文件传不了 数据端口是否全部可达,不要只放行控制端口
新任务无法启动 是否已有发送或接收任务尚未退出
接收因冲突停止 修改冲突策略或保存目录后重新发起

首次使用时,可以按下面的顺序检查:

  • 两端都在同一可互访局域网;
  • Relay 绑定的是实际 LAN 地址,不是 loopback;
  • 两端 Profile 都指向同一个 Relay;
  • Relay 密码、控制端口和数据端口配置正确;
  • 连接测试成功,数据端口也已放行;
  • 接收方输入的是本次传输短语;
  • 保存目录可写,剩余空间足够;
  • 已确认完整清单并点击 Accept;
  • 最终状态确实为完成,而不只是进度条接近 100%。

需要反馈问题时,可以在“设置”导出安全诊断包,同时提供版本、错误类别和复现步骤,不要把真实短语或密码贴进问题描述。


十一、当前完成了什么,还有什么没有验证

截至本文整理时,项目完成了本地 MVP 实现、自动化测试、前端构建和 Windows Go 交叉编译。

以下项目仍需要实际环境验收:

  • Windows 双机局域网传输;
  • DPAPI 实际读写和 Windows 文件权限;
  • 系统、缺失和固定版 WebView2 的启动行为;
  • 原生文件选择器、剪贴板、窗口缩放和视觉效果;
  • 防火墙与网络 profile 的实际检查;
  • 便携包、NSIS 安装升级卸载、签名与 SmartScreen;
  • Docker Compose 与镜像部署。

因此,当前更准确的定位是:

可以继续进行 Windows 真机验收的桌面 MVP,而不是已经完成全部安全与发行验证的成品。

工程占位图标、已知 logger race、没有应用内扫码和 Linux 桌面支持,也都是需要如实保留的限制。


十二、总结

这个项目最有价值的部分,并不是给命令行工具增加几个按钮,而是把传输能力放进一个边界明确的桌面流程:

1
2
3
4
5
6
7
8
9
10
11
显式局域网连接
+
人工确认文件清单
+
结构化事件与可靠取消
+
临时接收与最终提升
+
秘密生命周期管理
+
可复查的测试证据

复用成熟传输引擎可以减少协议层的工作,但它不会自动解决 GUI 确认、文件冲突、日志秘密、任务销毁和离线依赖这些产品问题。

对我而言,这次实践形成的一个重要原则是:

能完成一次文件传输,只说明传输路径跑通了;要让它成为一个可以交给别人使用的工具,还需要把失败、取消、安全落盘和验证边界一并设计清楚。


项目与参考资料

项目自身代码和文档采用 MIT License,croc 及其他依赖遵循各自许可证。本文对应的是本地已实现版本,仓库链接中的具体内容以作者同步后的提交为准。