首页
/ LocalSend 工程指南:从 AGENTS.md 深入其 Flutter + Rust 混合单体仓库的开发工作流与协议架构

LocalSend 工程指南:从 AGENTS.md 深入其 Flutter + Rust 混合单体仓库的开发工作流与协议架构

2026-09-04 11:33:17作者:凌朦慧Richard

LocalSend 是一个用 Flutter 实现 UI、用 Rust 实现底层协议与网络服务的跨平台开源项目。本文以仓库根目录下的 AGENTS.md 为主线,完整梳理这个多语言 monorepo 的目录结构、双工作区(Cargo workspace + pub workspace)组织方式、必须牢记的构建与检查命令,以及 HTTP 服务、组播发现、Isolate 分层等核心架构设计,帮助你在动手修改任何模块前就建立起与 CI 一致的正确认知。

仓库布局:Flutter 应用骑在 Rust 协议实现之上

AGENTS.md 开宗明义:这是一个多语言 monorepo,"a Flutter app on top of a Rust protocol implementation"。各路径的职责如下表(继承自原文档并补充说明):

路径 内容
app/ Flutter 应用(localsend_app):UI、providers、持久化、平台通道
packages/localsend_isolates/ Dart isolate 层 + flutter_rust_bridge(FRB)绑定;内部拥有 rust/(Flutter 插件 crate rust_lib_localsend_app)与 rust_builder/(cargokit 构建器)
packages/core/ Rust crate localsend:协议、HTTP 服务端/客户端、加密、WebRTC,不依赖 Flutter
packages/typed_isolates/ 小型独立包,用类型化的 send/receive 通道封装 Dart Isolate
server/ 供 WebRTC 使用的 Axum WebSocket 信令服务器(/v1/ws),独立部署,见 server/Dockerfile
cli/ Rust CLI crate(localsend-cli):基于 packages/core(v2 HTTP + 组播)的交互式终端客户端
support/scripts/ 发布/打包脚本(各平台构建、MSIX、Inno Setup、FOSS 剥离)

从源码结构看,packages/typed_isolates 的入口 typed_isolates.dart 仅导出 isolate_helperisolate_taskisolate_task_helperisolate_task_result 四类符号,印证了文档中"小工具包"的定位。

双工作区:Cargo workspace 与 pub workspace

四个 Rust crate(packages/corepackages/localsend_isolates/rustservercli)构成以仓库根为根的单一 Cargo workspace:共享一份 Cargo.locktarget/ 目录。这一点可以从根 Cargo.toml 得到直接确认——[workspace] 段显式列出四个成员,并附带两条值得注意的工程决策注释:

[workspace]
resolver = "3"
members = [
    "cli",
    "packages/core",
    "packages/localsend_isolates/rust",
    "server",
]

# Profiles are only honored in the workspace root manifest.

# Full debuginfo dominates target/ size; line tables keep backtraces usable.
[profile.dev]
debug = "line-tables-only"

# RSA key generation is bignum-heavy and takes ~10x longer unoptimized;
# keep the crypto crates optimized in dev so tests stay fast.
[profile.dev.package.rsa]
opt-level = 2

[profile.dev.package.num-bigint-dig]
opt-level = 2

由此可提炼出三条硬性规则:

  1. [profile.*] 设置写在根 Cargo.toml,成员 crate 里的 profile 会被直接忽略——这也是根文件注释"Profiles are only honored in the workspace root manifest"的含义;
  2. debug = "line-tables-only" 是为压缩 target/ 体积而做的取舍(保留行表让 backtrace 可用);
  3. rsanum-bigint-dig 单独提升 opt-level,因为大数运算在未优化状态下会慢约 10 倍,直接影响加密相关测试的速度。

需要注意的一个例外:Flutter 构建时,cargokit 仍会把插件 crate 编译进它自己的 target 目录,而非共享的 target/

Dart 侧同样如此:三个包(apppackages/localsend_isolatespackages/typed_isolates)构成以仓库根为根的 pub workspace。根 pubspec.yaml 只有 9 行:

name: _localsend_workspace
publish_to: none

environment:
  sdk: ^3.11.0

workspace:
  - app
  - packages/localsend_isolates
  - packages/typed_isolates

共享一份 pubspec.lock.dart_tool/,从任意成员执行 pub get 都会解析整个工作区。但 rust_builder 与 vendored 的 cargokit/build_tool刻意排除在工作区之外,仍独立解析依赖。

依赖方向

app → localsend_isolates → (typed_isolates, rust_lib_localsend_app → localsend core)

一个容易踩坑的约束:app 依赖 localsend_isolates——不直接依赖 flutter_rust_bridgetyped_isolates 或插件 crate。做 import 时若发现需要绕过这一层直连底层,应视为架构违规信号。

Flutter 版本管理:用 fvm,别用系统工具链

仓库通过 .fvmrc 固定 Flutter 版本,该版本同时镜像在 CI 工作流与 app/pubspec.yaml 中,还有 support/submodules/flutter 这个 git submodule。因此所有命令都应使用 fvm flutter / fvm dart 而不是系统全局的 flutter / dart——CLAUDE.md 同样反复强调这一点,因为它只有一句话核心:"Always use fvm flutter / fvm dart, never the bare binaries",并把完整指引指向 AGENTS.md。

版本号一旦升级,需同步更新四处(.fvmrc、CI 工作流、app/pubspec.yaml、flutter submodule),具体步骤见 CONTRIBUTING.md 的 "Bump Flutter" 小节。

开发命令全集:与 CI 保持一致

以下命令除特别注明外都在 app/ 下执行,全部继承自原文档:

fvm flutter pub get
fvm dart run build_runner build  # dart_mappable, freezed, flutter_gen, mockito
fvm dart run slang               # i18n 代码生成(slang_build_runner 在 build.yaml 中被关闭)
fvm flutter run

CI 执行的检查项:

fvm dart format --set-exit-if-changed lib test   # CI 会先删除 lib/gen;生成代码不做格式检查
fvm flutter analyze
fvm flutter test
fvm flutter test test/unit/util/security_helper_test.dart          # 单文件
fvm flutter test --plain-name 'some test name'                     # 单条用例

两个格式细节直接影响代码评审体验:

  • 行宽为 150 列,且 trailing_commas: preserve。可在 app/analysis_options.yaml 中确认:

    formatter:
      trailing_commas: preserve
      page_width: 150
    

    任何把生成代码重排成 80 列的工具都会制造纯噪音,事后要用 fvm dart format 修正;

  • CI 在格式检查前会先删掉 lib/gen,所以生成代码的格式状态不必纠结。

Rust 侧命令:

cargo test --features full       # 在 packages/core 下 —— 见下文“Core crate features”
cargo clippy --features full
cargo check                      # 在 packages/localsend_isolates/rust、server、cli 下

FRB 代码生成在 packages/localsend_isolates/ 下运行,配置见 flutter_rust_bridge.yaml

rust_input: crate::api
rust_root: rust/
dart_output: lib/rust
dart_format_line_length: 150

即输入是插件 crate 的 crate::api 模块,Dart 输出到 lib/rust,且生成代码沿用 150 列格式。有两个实战注意点:

  1. 代码生成器会把 app/test/mocks.mocks.dart 重写成 80 列——如果这个文件出现在 diff 里,直接还原它;
  2. packages/localsend_isolates 有自己独立的 build.yaml,其 model 变化后需要在该包内单独再跑一次 build_runnerpub get 由工作区共享);CI 还会额外在 packages/localsend_isolates/rust_builder/cargokit/build_tool 里跑一次 flutter pub get

Core crate 的 feature 门控:永远加 --features full

packages/core/Cargo.toml 把几乎一切都藏在 Cargo feature 后面,且 default = []

[features]
default = []
crypto = ["ed25519-dalek", "rcgen", "rsa", "sha2", "tokio-util"]
discovery = ["http", "multicast"]
http = ["crypto", "form_urlencoded", "http-body-util", "hyper", "hyper-util", "if-addrs", "pem", "percent-encoding", "reqwest", "rustls", "socket2", "time", "tokio-rustls", "tokio-util", "x509-parser"]
multicast = ["if-addrs", "socket2", "tokio-util"]
webrtc-signaling = ["tokio-tungstenite"]
webrtc = ["crypto", "flate2", "dep:webrtc", "webrtc-signaling", "x509-parser"]
full = ["crypto", "discovery", "http", "multicast", "webrtc"]

文档给出的结论必须严格执行:构建与测试 packages/core 时永远带 --features full。裸 cargo check / cargo build 会失败,原因是各模块被无条件声明而依赖却是可选的——这是历史遗留的既定状态,不是回归 bug,排查时不要误判。

架构:状态管理、Isolate 分层与 Rust 网络栈

状态管理:Refena,不是 Riverpod

应用使用 refena_flutter 管理状态。providers 位于 app/lib/provider/

  • 普通状态用 NotifierProvider
  • 凡是 isolate 层会触碰的状态,一律用 ReduxProvider + 分发的 action 类;
  • app/lib/config/init.dart 中的 preInit 是启动引导:依次初始化日志、RustLib.init()、持久化、isolate 容器、tray/window,最终返回 main.dart 挂载用的 RefenaContainer

数据模型层有两套代码生成:

  • dart_mappable@MappableClass + .mapper.dart part 文件,仓库中如 app/lib/model/state/settings_state.mapper.dart 等可见其产物),并且方法被重命名fromJson/toJson 是 Map 转换器,deserialize/serialize 才是字符串转换器(在两个 build.yaml 中配置);
  • Freezed 用于 FRB 侧的 union 类型。

Isolate 分层:重网络永不跑在主 isolate

packages/localsend_isolates/lib/src/isolate/ 的职责划分非常清晰:

  • parent/parent_isolate_provider.dart —— ParentIsolateState 为每个子 isolate(http 扫描发现、组播发现、http 上传、http 服务器)各持有一个 IsolateConnector,外加一份镜像进所有子 isolate 的 SyncStateIsolateSetupAction 负责生成它们;
  • parent/actions.dartparent/actions_sync.dart —— 这是应用与子 isolate 通信的唯一受支持方式
  • child/*_isolate.dart —— 子 isolate 入口,把类型化任务消息翻译为对 lib/src/task/ 的调用;
  • lib/src/task/ —— 只放纯工具函数。目录内 README.md 原文只有两行,但态度坚决:"All files in this directory should only call utility functions. Isolate logic is prohibited in this directory."(本目录禁止出现任何 isolate 逻辑)

一条时序规则值得警惕:子 isolate 需要的状态(alias、端口、协议、服务器是否运行、web send 是否开启)通过 IsolateSyncServerStateAction 推送,而子 isolate 是在启动时读取 syncState 的——所以必须先同步、后启动服务器

Rust 网络栈:HTTP 服务器与事件通道

HTTP 服务端与客户端都写在 Rust 里,位于 packages/core/src/http/server/

  • 实现协议 v2(不提供 v1 端点),外加 "web send" 下载流(server/web.rs)以及一个内部 show 端点,用于把已运行的实例拉到前台;
  • 集成是基于 channel 的:start_with_port 接收 ServerConfigV2 { pin, event_tx, web_send },向外发出 ServerEventV2 事件——Register、携带 decision_tx oneshot 的 PrepareUpload、携带字节流与 result_txFileUploadPrepareDownloadSessionEndPrepareUploadAbortedCancelReceived

设计约束逐条展开:

  1. 同一时刻只有一个上传会话处于活动状态;取消安全性由 drop 守卫(PendingSessionGuardUploadGuardPendingWebSessionGuard)保证,即会话随 guard 析构而自动清理;
  2. core 中故意没有 auto_accept——自动接受是由应用层立即回答 decision_tx 实现的。这个设计把"策略"(是否接受)留在 Dart 应用层,core 只提供"机制";
  3. 新增服务器→应用交互应扩展 ServerEventV2,而不是另开旁路通道。

FRB 层(packages/localsend_isolates/rust/src/api/server.rs)暴露 start_server 与一个不透明句柄 RsHttpServer,其 listen 把 v2、web-send、internal 三条事件流合并为一条 RsServerEvent 流;responder oneshot 保留在 Rust 侧。Dart 侧 child/server_isolate.dart 将其转为 HttpServerEvent,再由 app/lib/provider/network/server/server_provider.dart 路由到 ReceiveController / SendController——注意它们是事件处理器,不是路由处理器

其余几个容易踩坑的事实:

  • 保存目标在 Dart 端决定prepareFileSaveTarget),实际写入由 Rust 执行:要么是一个路径,要么是通过 org.localsend.localsend_app/localsend method channel 获得的 Android SAF 文件描述符;保存进相册的文件先经过缓存文件中转;
  • 服务器事件里的 ipPeerIp(IP + IPv6 scope),链路对端会显示为 fe80::1%3 这类带 scope 的形式,而 HTTP 客户端也接受它作为 host 回拨——这样事件中的 ip 保持可直连(dialable);
  • TLS 采用每设备即时生成的证书 + 强制客户端证书(浏览器访问 web 页面时证书可选,保证浏览器可连);对端身份是客户端证书 DER 的大写十六进制 SHA-256,若 payload 声明的指纹与证书不符,Register 事件干脆不发出。代码中应优先取 event.certFingerprint ?? event.info.fingerprint——payload 回退只服务于关闭加密的模式;
  • 接收 pin 与 web-send pin 都在服务器启动时固定,改任何一个 pin 都会重启服务器
  • 浏览器下载页的 web 资产内嵌自 packages/core/assets/web/download.htmlupload.htmlerror-403.html)。

组播发现:UDP 只负责宣告,应答走 HTTP

packages/core/src/multicast/(feature multicast,与 http 独立)实现协议 v2.2 的 UDP 组播发现,v1 消息不再解析。集成方式与 HTTP 服务器对称:multicast::start 接收 MulticastConfig { group, group_v6, port, interface_filter, device, event_tx },发出 MulticastEvent::Discovered { ip, message },返回的 MulticastHandle 提供 announce(宣告突发)与 wait_stopped

关键设计点:

  • UDP 只做宣告(announce-only):应答通过 HTTP 以单播 register 请求发回宣告方;
  • 每个接口的 IPv4 地址各绑定一个 socketSO_REUSEPORT/SO_REUSEADDR + IP_MULTICAST_IF),因为单个 socket 只能从一个接口发送;
  • 组播回环(loopback)保持开启,让同一主机上的多个实例能互相发现;自己的消息由指纹识别后丢弃;
  • IPv6 是 LocalSend 的扩展实现(组播组 ff12::fd3a:e420,常量 DEFAULT_MULTICAST_GROUP_V6),通过设置 group_v6 启用:每个接口一个 IPV6_V6ONLY socket,按接口索引加入;Discovered 事件携带源端的 scope ID(接口索引),链路本地 IPv6 源回 HTTP 应答时需要它。

i18n 与 FOSS 构建

i18n:Slang + Weblate

翻译源文件在 app/assets/i18n/<locale>.json_missing_translations_<locale>.json,仓库中可见 60+ 语言文件),生成输出在 app/lib/gen/。翻译托管在 Weblate;以 @ 开头的字段是面向译者的元数据,应用本身不使用。app/test/unit/i18n_test.dart 守护语言集合的完整性。

FOSS 构建:靠注释标记剥离

F-Droid 版由 support/scripts/remove_proprietary_dependencies.sh 剥离 in_app_purchase 与捐赠 UI,机制完全依赖三处标记:

  • pubspec.yaml 中依赖行的 # [FOSS_REMOVE] 注释(脚本用 sed '/# \[FOSS_REMOVE\]/d' 整行删除);
  • Dart 文件中的 // [FOSS_REMOVE_START] / // [FOSS_REMOVE_END] 注释对(脚本将其改写为 /**/,形成块注释);
  • lib/provider/purchase_provider.dart 被整文件删除,且 donationPageVmProvider 被替换为 donationPageNoopVmProvider

因此编辑 app/lib/config/init.dartapp/lib/pages/donation/*app/lib/provider/purchase_provider.dart必须保留这些标记,否则 FOSS 构建会损坏。

发布注意事项:三处版本号必须一致

app/pubspec.yaml 的 version 必须与 support/scripts/compile_windows_exe-inno.iss 中的 #define MyAppVersion 以及 cli/Cargo.tomlversion(CLI 在启动横幅中打印它)保持一致——CI 对不一致直接判失败。各平台构建命令与发布流程详见 README.md 的 "Building" 小节与 CONTRIBUTING.md 的 "Release" 小节。

小结

AGENTS.md 的核心信息可以浓缩为五件事:

  1. 记住双工作区(Cargo + pub)与四条依赖方向,改 profile 只改根 Cargo.toml,改依赖先想清楚该落在哪个 crate/包;
  2. 全程 fvm,格式 150 列,生成文件(lib/genmocks.mocks.dart)的格式噪音要手动清理;
  3. packages/core 就带 --features full,裸编译失败是预期行为;
  4. 网络事件只走 ServerEventV2 / 合并后的 RsServerEvent 单流,策略(如 auto-accept)留在 Dart 应用层,isolate 逻辑禁止下沉到 task/
  5. 动到捐赠、内购相关文件或准备发版时,核对 FOSS 标记与三处版本号。

按这套约束工作,你的本地构建、检查与 CI 的行为会保持完全一致。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.12 K
2.72 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
528
588
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
906
1.82 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
854
1.34 K
docsdocs
暂无描述
Markdown
891
5.78 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.53 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.34 K
1.45 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
987
504
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
540
384