LegnaSend
开发文档English

可选的原生同会话接收续传协议

本文保留最初的同会话协议基线。当前已新增协商式跨任务持久续传,完整增量契约、校验阶段、暂停和清理规则见持久续传。下文“未实现跨重启”等边界仅指未协商持久能力的原模式。

本文描述当前实现的 LegnaSend 扩展,协议版本为 1。它不是管理端 bearer 密钥 API,不替换 LocalSend v2,也不是浏览器下载恢复或跨重启持久传输协议。

协商与适用范围

首先完成保持不变的 LocalSend v2 准备与接收审批握手,然后使用其中已批准的 sessionId、fileId 和该文件自己的 token。不会要求原版 LocalSend 增加必填字段或接口。

发送端只考虑至少 1 MiB(1,048,576 字节)、初始位置为零的普通文件。探测能力前先打开来源并保存大小/修改时间快照,能力成功后才计算完整 SHA-256。小文件、字节流和不支持的来源描述符保持整文件发送。来源发生变化时失败,不静默切换成新内容。

接收端仅为明确批准使用普通持久缓存路径的文件发布能力。图库后处理、Android SAF/content URI、Android SD 卡提供器目标保持原始 v2 整文件传输。固定每块 1 MiB,当前最多 1,048,576 块,即最大 1 TiB。

只有能力接口返回 HTTP 404、405 或 501,才回退到原始 POST /api/localsend/v2/upload,正文仍是完整原始文件字节。鉴权错误、超时、畸形能力、哈希错误及后续续传失败都不是该回退条件。成功能力必须包含 version: 1、supported: true、blockSize: 1048576;当前不以成功响应中的 supported: false 表示不支持。

身份校验与公共查询参数

基础路径:/api/legnasend/v1/receive-resume/。

所有请求携带经过 URL 编码的 sessionId、fileId、token。接收端还核对原始发送者的来源 IP(适用时包括 IPv6 scope)及 TLS 证书指纹。HTTP 模式没有证书身份,但仍核对已批准的来源和 token。HTTPS 使用与原生传输相同的证书固定、任务路由绑定客户端。管理端 bearer 密钥不赋予访问这些接口的权限。

block、status、finish、abort 还需携带 open 返回的 resumeId;block 另需十进制 offset。查询字段按操作白名单校验,重复或空字段、未知字段、超过 4096 字节的值、超过 16 KiB 的原始查询串均拒绝。token 和查询串属于敏感信息,不应写入诊断日志。

六个接口

方法及路径后缀请求成功响应
GET capabilities公共查询参数,无正文能力 JSON
POST open公共查询参数;JSON 包含 size、sha256回执 JSON
PUT block公共参数及 resumeId、offset;原始块字节;头 X-LegnaSend-Block-Sha256块提交后的回执
GET status公共参数及 resumeId当前回执;操作仍占用时返回 409
POST finish公共参数及 resumeId,无需正文校验并发布成功后返回 state: "complete" 回执
POST abort公共参数及 resumeId,无需正文{"version":1,"state":"cancelling"};若已发布则返回完成回执

能力示例:

json
{"version":1,"supported":true,"blockSize":1048576}

打开请求正文,实际调用必须将示意哈希替换为完整文件的真实 SHA-256:

json
{"size":2097153,"sha256":"0000000000000000000000000000000000000000000000000000000000000000"}

size 必须等于 v2 已批准的文件大小;原 DTO 如有校验和也必须一致。即使原始准备请求没有校验和,这里仍要求完整 SHA-256。接受恰好 64 个十六进制字符并转成小写,拒绝未知 JSON 字段。打开正文限制为 1024 字节,正文读取期限为 10 秒。

回执示例:

json
{
  "version":1,
  "resumeId":"11111111-1111-4111-8111-111111111111",
  "blockSize":1048576,
  "offset":1048576,
  "size":2097153,
  "sha256":"0000000000000000000000000000000000000000000000000000000000000000",
  "state":"receiving"
}

对外回执状态为 ready、receiving、complete。初始化期间返回 409;失败、取消或过期条目返回 410,而不是可继续使用的回执。同一已鉴权 session/file/size/hash 重复 open 会复用条目,不重复分配保存目标;内容身份不同返回 409。

区块与恢复规则

  • 每次发送恰好 min(1048576, size - offset) 个字节,不使用 multipart 包装或聚合文件格式。偏移顺序推进,必须等于接收端已提交偏移;非最终偏移按区块边界对齐。
  • 区块头中的 SHA-256 是本次原始块字节的哈希,不是整文件哈希。长度不足返回 400 并取消该条目;块哈希不符返回 422 并取消。正文超过预算返回 413,区块正文读取期限为 60 秒。
  • 同一条目同时只允许一个操作持有;忙碌时查询或写入返回 409。不能将忙碌理解为检查点丢失,也不能因此创建新会话。
  • 块或完成请求响应丢失时,先查 status 再发送。已经提交的块可以在 HTTP 连接断开后保留。偏移未变时可重试未确认块;偏移已推进则跳过已确认字节。盲目重发已提交偏移会收到 409。
  • 当前发送端共用最多三次重连/忙碌退避,分别等待 1、2、4 秒,并检查取消。打开请求可幂等重试;区块/完成请求的传输错误或 409 通过 status 核对。每个续传 HTTP 请求超时为 30 秒,成功与错误响应正文预算均为 8192 字节。
  • 发送端拒绝 resume ID、大小、哈希、块大小不匹配,非法对齐,检查点倒退,status 超过最近可能提交的块尾,普通块回执未落在本次块尾,以及 finish 非完成状态。上述错误不会转换成整文件回退。

发布、取消与生命周期

已经收到字节或 offset == size 不等于最终成功。finish 校验完整缓存,向本次拥有的暂存文件导出原始字节,再以不覆盖已有目标的方式发布;发送最终成功回执前清理本次缓存和暂存。缓存是目标目录旁的 .ls 文件,不是交付文件格式,也不是新的原版传输格式。

HTTP 请求中断本身不会取消已批准文件。接收端在短暂恢复窗口中保留内存事务;工作线程自初始化或最后一次成功提交区块起 60 秒后过期,在操作之间检查。轮询 status 不延长期限。获取保存目标也有 60 秒期限。正在阻塞的操作系统发布调用不会在某个计时点被强行撤回。

发送端取得有效 resume ID 后,传输失败或取消会尽力调用该文件的 abort,预算为两秒;不会为此取消同会话其他文件。显式原始会话取消或监听停止会撤销对应续传条目。对已完成条目调用 abort 返回完成回执,不删除已交付文件。

成功事务最终处理后,完成回执保留身份与元数据约 60 秒,不保留目标描述符,供完成响应丢失后的核对使用;停服或会话撤销可提前移除。注册表最多 72 个条目,与普通缓存接收共享最多八个缓存工作线程,容量不足返回 429。

主要错误:400 请求或正文无效、403 身份/token 不符、404 能力不支持或操作未知、408 正文超时、409 忙碌/检查点/身份冲突、410 条目缺失/撤销/过期、413 正文超限、422 块哈希不符、429 容量不足、500 初始化/存储/发布失败。取消竞态中可能返回 410 而不是最终回执,应以实际结果核对,不能仅凭已发送字节推断文件已发布。

明确边界与证据

当前不支持应用或监听重启后重建原会话/resume ID。重新批准的新会话是新尝试;已有启动清理负责本应用拥有的非活动残留,.ls 记录持久存在不代表线上的跨重启恢复能力。SAF、图库和提供器目标续传未由此扩展实现。同一续传文件按块顺序上传,不是多线程并行上传。

实现文件:packages/core/src/http/client/resumable_upload.rs、packages/core/src/http/server/receive_resume.rs、packages/core/src/http/server/common/receive_cache.rs、packages/localsend_isolates/lib/src/task/server/receive_resume_capability.dart。

abort应答表示已请求取消,不表示与取消并发的磁盘发布已撤回;已经发布的文件保留实际成功结果,不删除成品。

来源结束控制扩展

这是可选的持久恢复扩展,不适用前文的共同会话查询参数规则,也不改变任何原版 LocalSend v2 必需字段或接口。

能力示例:

json
{"version":1,"supported":true,"blockSize":1048576,"durable":{"version":1,"sourceEnd":{"version":1}}}

主动启用时,在持久 open 的 recovery 对象内增加 "sourceEnd":1;省略时旧请求形状和行为仍然有效。目标与记录核验完成后,回执增加:

json
{"sourceEnd":{"version":1,"grantId":"GRANT_UUID","round":"ROUND_UUID","token":"BASE64URL_32_RANDOM_BYTES","expiresAtUnixMs":1800000000000}}

初始 verifying 回执可以省略该字段。支持此能力的发送端必须先收到授权并持久保存,再发送第一个文件块。当前私有宿主确认最多等待 30 秒且支持取消。恢复键不等于授权,明确协商为不支持与请求未响应也不同。

POST /api/legnasend/v1/receive-resume/source-end 不携带查询参数,JSON 正文为:

json
{"version":1,"requestId":"REQUEST_UUID","grantId":"GRANT_UUID","round":"ROUND_UUID","token":"BASE64URL_32_RANDOM_BYTES"}

正文上限 4096 字节,读取期限五秒,UUID 使用标准规范格式。未知字段、格式错误的密钥及查询参数均拒绝。TLS 沿用原固定证书客户端,接收端匹配登记的对方证书;关闭 TLS 时严格匹配原 IP。密钥不进入网址、日志或公共管理 API。

HTTP 200 返回的是明确结果类型,不是无条件成功:

json
{"outcome":"cleared","receiptId":"RECEIPT_UUID","removedFiles":1,"unlinkedBytes":1049000}

结果包括 cleared、publishedPreserved、active、publicationPending、retainedUnknown、unknownOrExpired、superseded、authorizationRequired。只有 cleared 是清理回执。publishedPreserved 表示没有删除已发布目标;其它结果保留实际不确定性或保护状态,不得显示删除成功。回执身份可以为空,受保护或未知结果的删除计数为零。同一有效授权重复请求会重放原持久回执及计数,不重复统计删除。网络请求失败仍是结果未知,不冒充任何明确结果。

清理先获取真实非活跃记录锁,再核验轮次、缓存事务和文件身份。新附加会替代旧授权,旧授权不删除新附加记录,活跃或正在发布的操作不强制取消。已发布文件绝不作为删除目标。原记录到期时间为绝对期限,重试不会延长。控制请求最多占用四个实际工作线程,授权账本最多 256 条;额度不足不能绕锁或切换旧接口。