跳转至

11. 客户端 SDK(通信层)

目标形态:一份 Rust 核心服务所有客户端,绑定一律由工具生成。 一期实现桌面(Windows / macOS / Linux),移动端与浏览器按 ADR-0010 §3 的可移植约束预留,加平台不改核心。 契约来源:docs/PLAN.md §6.8 游标、§6.9 排序、§9.3 同步、§9.6 REBUILD、 §27.3 本地存储、附录 A 帧总表、附录 B 参数表;ADR-0002 传输、ADR-0008 登录对账、 ADR-0010 本 SDK 的形态与绑定策略。

1. 这个 SDK 存在的理由

不是"把帧收发包一层"。协议帧只占实现量的一小部分,真正难的是一组一旦写错就 静默丢消息或重复气泡的端上规则——它们分散在 §6.8 / §9.3 / §9.6 / §27.3, 每接入一个客户端就要重写一遍,而错误不会在联调时暴露,会在用户断网重连、 换设备、翻页到边界时暴露。

SDK 的价值判据只有一条:上层应用即使想违反这些规则也做不到。 凡是"上层自觉遵守"的约定,都不算落地(§5 逐条给出结构性保证的手段)。

1.1 一期实现范围

"通用"指核心只有一份,不指一期把所有端都实现完。 下表区分「现在实现」与「接口现在定、实现按需补」——后者不需要改核心, 前提是 ADR-0010 §3 的五条可移植约束从第一天就遵守。

项 一期 说明
桌面(Win/macOS/Linux) 实现 Transport = TCP+TLS,LocalStore = SQLite
iOS / Android 绑定就绪 UniFFI 的 Swift/Kotlin 目标已生成(scripts/gen-bindings.sh);只需按目标平台重新 cargo build(aarch64-apple-ios / aarch64-linux-android)
浏览器 / WASM 接口就绪 需补 Transport = WebSocket、LocalStore = IndexedDB 两个实现
E2EE 不做 §21 的 sender-key 模型服务端未接入;本地库预留密文列
媒体上传下载 不做 MediaService 未实现;local_media_cache 建表不实现回源
离线推送 不做 NotificationService 未实现(§16)

2. 与服务端共用契约代码(最重要的结构性收益)

qim-proto   常量 / ID 位布局 / 路由 / opcode / 编译期断言
qim-codec   20 字节帧头 + CRC + 分片 + 压缩
     ↑                    ↑
服务端 5 个主链路可执行体  qim-sdk

SDK 直接依赖服务端同一份 qim-proto 与 qim-codec,不另写一套。 后果是帧布局、opcode 值、mailbox_seq 位宽、lane_id 推导、bit63 断言 在两侧不可能漂移——协议不一致这类最常见的联调故障被编译期消灭。

代价是 SDK 继承了这两个 crate 的依赖。可接受:二者都无 IO、无运行时依赖。

docs/01 §5.3.5 要求 TCP 与 WebSocket 两条路径复用同一 codec 实现。 一期只做 TCP,但 codec 已在独立 crate,将来加 WS 承载不触碰 SDK 核心。

3. 分层与 crate 划分

绑定层(全部生成,无手写代码)
   qim-sdk-uniffi    Swift / Kotlin / Python      ← 移动端与脚本层
   qim-sdk-ffi       C ABI cdylib + cbindgen 头   ← C/C++/Tauri/任意 dlopen
   qim-sdk-wasm      JS / TS(浏览器端立项时打开)
        ↓ 只依赖公开面:句柄 + 值类型 + 单向事件流
qim-sdk         纯 Rust 核心(Rust 应用可直接依赖)
   ├── api      命令、事件、状态快照 ← 唯一公开面
   ├── conn     握手、心跳、重连退避、顶号(帧收发在 Transport 后)
   ├── sync     登录同步、登录对账、REBUILD、游标推进
   ├── state    客户端状态机(自 qim-loadgen 抽出)
   └── port     平台抽象:Transport / LocalStore / Clock / Spawn
        ↓
平台实现(按目标编译,核心不感知)
   native:  TCP+TLS   SQLite    tokio
   wasm:    WebSocket IndexedDB 宿主事件循环     ← 接口就绪,实现按需补
   test:    内存回环   内存表    确定性时钟      ← 单元测试与 loadgen

qim-codec / qim-proto                                 ← 与服务端同一份

port 层是"一份核心服务所有端"的落点。加一个平台 = 加一组 Transport / LocalStore 实现;加一门语言 = 加一个生成器目标。 两者都不改 api 与 sync——端上契约只有一份实现,这正是不维护多套的含义。

测试实现(内存回环 + 确定性时钟)不是附赠品:注入时钟后状态机的重连退避、 心跳超时、pending 老化都能在毫秒内确定性验证,不必靠 sleep 等真实时间。

qim-loadgen 依赖 qim-sdk,并划出一条 SDK 回归泳道:压测总连接的 5%(--sdk-clients 可调,0 = 关闭)由真实 SDK 驱动,两两配对互发, 收尾断言三条不变量——PUSH_EVENTS 未推进游标、message_id 去重无漏、 local_pending 已清零。这不是省代码,是回归保护:SDK 的游标、去重、 对账逻辑从此每次性能门禁都在真实负载下被执行,而不是只有单元测试覆盖。 (泳道用 MemoryStore,避免上千个 SQLite 实例。)

泳道占用连接而非追加,总发送速率因此不变,分位数与历史结果可比较。

两点必须说清,否则会得出错误结论:

  1. 其余连接刻意留在帧层(net::Client)。压测要的是 SEND_ACK 与 PUSH_EVENTS 的精确时刻,以及只经 ConnectionNode 的 PING 对照组—— 后者是把接入层排队与下游处理分开归因的唯一手段(writer→mailbox 长尾 就是靠它定位的)。全量走 SDK 会丢掉对照组,并把客户端落库耗时混进 "端到端时延",让一次客户端抖动被读成服务端退化。
  2. 泳道不能自发自收。SDK 对自己发的消息走对账(SEND_ACK → ResolvePending),回声到达时按 message_id 去重后静默丢弃, 不产生 MessagesAdded(否则 UI 会把同一条显示两遍)。也就是说 自聊时"消息是否真的走完全链路回到本端"在 SDK 公开面上不可观测, 必须配对互发。同理,泳道的到达判重只能按 message_id, 不能按 conversation_seq——后者只在单会话内唯一,登录同步拉回的 其他会话历史会与本会话 seq 空间重合,看起来像去重失效。

实测代价:1200 连接 / 15,000 msg/s / 30 s 下,开泳道端到端 P99 166 ms、 关泳道 141 ms(60 个完整 SDK 栈 + 每条消息一个 MARK_READ 上行跑在同一台 机器上),两者都在 300 ms 上限内。做容量结论时用 --sdk-clients 0 取同口径。

4. 线程模型与 API 形态

上层永远看不到 async。 运行时由绑定层持有(native 为 tokio 独立线程, wasm 为宿主事件循环;核心自己不持有运行时,见 ADR-0010 §3 约束 1), 公开面是三件套:

命令   qim_send_message(h, conv_id, payload, out_cmid) -> Result
       立即返回(只做校验与入队),执行结果一律经事件流回来
事件   qim_next_event(h, timeout_ms) -> Event | Timeout
       单条有序流,上层自己 pump;已提供可选回调包装
状态   qim_get_state(h) -> StateSnapshot
       不可变快照,随时可取,用于渲染而非驱动逻辑

为什么事件用拉取而不是回调:回调要从 SDK 的 Rust 线程回调进宿主运行时, 跨 FFI 时的重入与线程亲和性问题是这类 SDK 最常见的崩溃来源 (宿主 UI 线程亲和、GC 语言的附加线程、回调里再调 SDK 造成死锁)。 拉取式把线程归属完全交给上层,每种语言都能用自己的方式包成回调或 Stream; 反过来不成立。回调包装作为可选项提供,实现上就是 SDK 内起一个 pump 线程。

事件流必须有序且不丢:有界队列 + 满时阻塞生产者(不丢弃)。 事件流丢事件等于 UI 与本地库不一致,而本地库已经落盘——那是无法自愈的分歧。 队列积压是上层消费太慢,正确处置是让它变慢,不是替它丢数据。

4.1 命令表(一期)

下表与 api.rs 的 Command 枚举逐项对应。协议里存在但 SDK 未提供的 能力见 §8——文档列出不存在的命令,绑定作者会照着写然后编译不过。

命令 说明
Connect / Disconnect 建连与主动断开;断开不清本地库。令牌在 ClientConfig 里给,SDK 不负责登录流程
SendMessage { conversation_id, payload, client_message_id, kind, custom_type } 立即落 local_pending 并返回,UI 可即刻上屏。client_message_id 由 Handle::next_client_message_id 分配
Resend { client_message_id } 用于已标记失败的 pending;成功路径由 SDK 在重连后自动对账重发
PullHistory { conversation_id, before_seq } 向更旧翻页,结果落库并经 MessagesAdded 回来
PullSessionList 拉服务端会话列表(未读会用本地定义式重算后再下发)
MarkRead { conversation_id, conversation_seq } 已读水位,本地乐观 + 上行,只进不退
PullMembers { conversation_id, offset, limit } 群成员分页
RoomJoin { room_id, after_room_seq } / RoomLeave { room_id } 加入/退出聊天室并请求短期回放
Handle::query_conversations / query_messages / query_conversation / list_pending(本地只读) 直接读本地库,不产生网络请求。不是 Command 而是句柄方法:它们要立即返回结果,走命令队列则要等一轮事件才能拿到

聊天室不写本地库:RoomJoin 的结果走 RoomEvents 而非 MessagesAdded (后者的语义是"已去重、已落库、已计入会话列表",聊天室三样都没有)。

4.2 事件表(一期)

下表与 api.rs 的 Event 枚举逐项对应。

事件 触发
ConnectionChanged { state, reconnect_attempt, next_retry_ms } 连接状态迁移(含 REBUILD:state = Rebuilding)
SyncProgress { applied, done } 登录同步推进
MessagesAdded { conversation_id, messages } 已去重、已落库的新消息(推送 / 同步 / 历史翻页同一出口)
MessageStateChanged { client_message_id, state, conversation_seq } pending → committed / failed
ConversationsUpdated { conversations } 会话行的当前绝对值(partial 列表,按会话覆盖)
ReadStateChanged { conversation_id, read_seq } 已读水位前进
MembersUpdated { .. } 群成员页
Kicked { reason } 顶号(REPLACED)等。SDK 不自动重连
Failed { error, retry_after_ms } 服务端 ERROR 与本地不可恢复错误
RoomEvents { room_id, events, latest_room_seq, replay_truncated } 聊天室广播或回放。不落库、不计未读、不改会话列表;replay_truncated 必须呈现给用户,否则他以为看到了全部回放
LocalStoreReset 本地库已整库重置(连同游标),上层应清空内存态重新查询

MessagesAdded 是新消息的唯一出口:实时推送与登录同步走同一条路径, 上层无从区分,也就无从对二者写出不同的(且必然不一致的)处理。

ConversationsUpdated 携带的未读永远是本地定义式的当前值, 包括服务端列表到达时那一次(见 §6.1)。上层直接覆盖即可, 不要做任何累加或"取较大者"——那会让刚清零的会话被重新标回未读。

同一批新消息,SDK 先发 ConversationsUpdated(已含这批)再发 MessagesAdded。上层若在 MessagesAdded 里再给未读 +1,同一条消息就数了 两遍(qim-gui 曾如此,为"非当前会话"补计数)。新消息的未读增量由前一个 事件全权负责,上层只需要覆盖。

4.3 状态快照字段

connection_state、sync_state、reconnect_attempt、next_retry_at_ms、 pending_count、oldest_pending_age_ms、unread_total、server_time_skew_ms、 last_error。

故意不暴露游标(见 §5)。

5. SDK 必须结构性保证的契约不变量

每条给出「契约出处 → 违反后果 → 本 SDK 用什么手段让上层违反不了」。 这一节是本文档的核心,实现评审逐条对照。

# 不变量 违反后果 结构性手段
1 游标只由 MAILBOX_BATCH 应用完成后推进(§6.8) 游标越过未应用区间 = 静默丢消息 公开 API 里没有游标:不可读、不可写、不可注入。游标是 store 私有列,只有 sync 模块在整批落库成功后推进
2 PUSH_EVENTS 不推进游标(§6.8) 同上 推送与同步走不同内部入口,推送入口在类型上拿不到游标推进能力
3 去重按 message_id 与 (mailbox_seq, event_ordinal, event_id) 双键,窗口 > fanout_retry_max_window(§27.3.1) 重复气泡 去重在落库事务内完成;窗口下界写成 const _: () = assert!(...) 编译期断言
4 重连后重发任何 pending 前必须先完成邮箱同步与对账(ADR-0008) 重复消息,或用户以为发失败 状态机上 Syncing → Reconciling → Online,Online 之前重试器根本不运行;无"跳过对账"的公开开关
5 会话列表是绝对值覆盖,禁止累加(§27.3.2) 未读永远清不掉或对不上 本地库无"未读 +1"这类写法;ConversationsUpdated 携带整条记录,按 projection_mailbox_seq 大者胜
6 本地库迁移失败必须连同游标一起丢(§27.3.3) 服务端认为已同步、本地却没有数据 —— 评审 001 的 B-1 同类静默丢消息 迁移失败路径只有一条:reset_all_including_cursor();不存在只清消息的函数
7 冲突只允许"丢弃本地、以服务端为准"(§27.3.2) 本地脏数据永久覆盖服务端事实 合并函数签名为 fn merge(server: X, local: X) -> X 且实现只可能返回服务端派生值。例外是三个活跃度字段(last_activity_id / last_message_id / latest_conversation_seq)取 MAX:服务端投影是可重建物化视图、允许滞后于消息本身,而这些值本就源自服务端且天然单调,拿旧快照整行覆盖会让会话行倒退(用户看到"最后一条消息是倒数第二条")。未读与投影版本仍完全由服务端说了算
8 排序只用服务端下发的排序键,客户端不得自造序号(§27.3.1) 分页重复或漏项 查询 API 只提供"按 §6.9 排序键分页",不提供任意插入位置
9 禁止把客户端时间戳作为增量同步下界(禁止方案清单) 晚到消息永久丢失 同步只接受 mailbox_seq 游标;client_send_ts 仅存于 local_pending,不参与任何查询下界

6. 本地库

SQLite(rusqlite bundled,WAL)。七张表按 §27.3.1 逐字落地: local_message、local_pending、local_conversation、local_cursor、 local_dedup、local_media_cache、local_meta。

  • local_schema_version 只前向迁移,逐版本执行,不跨版本跳跃。
  • 迁移失败或版本高于当前代码(降级安装)→ 不变量 6 的单一路径。
  • local_pending 在任何迁移与重置中必须保留:它是尚未提交到服务端的 用户数据。这是唯一不可丢的本地表。
  • 落库与去重、游标推进在同一事务内提交。分开提交就会出现 "去重记了、消息没落"或"消息落了、游标没推"的半状态。

trait LocalStore(§3 的 port 层)三个实现:SqliteStore(native 默认)、 MemoryStore(单元测试与 loadgen)、IndexedDbStore(浏览器端立项时补)。 都是 SDK 内的实现,不对上层暴露注入点——§27.3.2 的冲突规则与迁移纪律 正是最容易写错、且写错就静默丢消息的部分,交给应用层实现等于把 SDK 存在的理由让渡出去。

七张表的结构与迁移逻辑在 trait 之上、与后端无关:LocalStore 的方法 是"应用一批邮箱条目""推进游标""按排序键翻页"这类语义操作, 不是"执行这条 SQL"。否则每个后端都要重写一遍冲突规则,那就是维护多套。

一期不做落盘加密。密钥托管(Keychain / DPAPI / libsecret)与 SQLCipher 的构建代价留 ADR-0011 单独裁决,届时只换 SqliteStore 一处。

6.1 未读的两个权威输入

未读在读路径按定义式现算,不存缓存列(docs/PLAN.md §12.5.1):

unread(u, c) = |{ m | m.conversation_seq > read_conversation_seq
                      ∧ m.sender_id ≠ u }|

两项都不是可选的,各自对应一个曾经出过的现场:

  • sender_id ≠ u:缺了它,自己发的消息在自己这侧算成自己的未读。 "发送即已读"(SEND_ACK 到达后把水位推到自己那条)只是让它不显形 ——本地水位一丢(换设备、清缓存、GUI 用内存库重登),会话列表的未读数 就恰好等于自己上一次发出去的条数。因此 LocalStore::open 必须收 self_user_id:本地库要算未读,就得知道"自己是谁"。
  • read_conversation_seq:它是 user 级、跨设备共享的主动状态, 只存在于服务端(read:{user})。协议若不下发,客户端换设备后只能从 0 起算,把同步回来的历史全计成未读。故 SESSION_LIST_BATCH.sessions[] 回带该字段(附录 A.4),登录序列自动拉一次会话列表把它取回来—— 不能等宿主开口,否则每个接入方都要自己记得拉,忘了就是一屏假红点。

合入规则只进不退(advance_read 内部取 MAX)。本地反而更高时, 说明离线期间标过已读而 MARK_READ 没送达,此时反向回补一次上行, 两侧收敛。水位是绝对值、幂等,重复发无害。

服务端一期的未读(session/unread.rs 的 ZCOUNT)没有 sender 项: Redis 的 zset 计数无法按发送者过滤,要过滤就得把区间读出来解码, 而那条路径是 P0 的会话列表首屏(§2.4,500 ms)。在"发送即已读"成立时 两者等价——服务端那份本就是缓存(§12.5.1 明写"以定义式为准"), 端上渲染用的是本地这一份。

7. 连接生命周期

Disconnected ──connect──> Connecting ──TLS+AUTH──> Authenticating
     ↑                                                   │ AUTH_OK(has_offline)
     │                                                   ↓
     │                                              Syncing(PULL_MAILBOX 流水线)
     │                                                   ↓
     │                                              Reconciling(ADR-0008 对账)
     │                                                   ↓
  Kicked / 网络断 ◄──────────────────────────────────  Online
                                                         │ CURSOR_EXPIRED
                                                         ↓
                                                     Rebuilding(§9.6)
  • 心跳:起始 60 s,前台上限 120 s,后台上限 240 s;间隔与超时由服务端 经 PONG 下发,SDK 只执行不自定(§6.2)。
  • 发送超时:3 s 无 SEND_ACK 触发 probe,端到端不可用检测 ≤ 7 s(§2.4)。
  • 重连退避:指数退避 + 抖动。抖动不是可选项——§5.3.4 的重连风暴 抑制要求错峰,无抖动的整齐退避会让全网客户端在同一时刻一起重连。
  • 顶号:收到 KICKED{REPLACED} 后不自动重连,转终态并发事件, 由上层决定(自动重连会与另一台设备形成互踢循环)。

8. 一期服务端能力边界(SDK 的降级形态)

gateway 当前实际处理的帧决定 SDK 能力上界。已核对代码:

帧 服务端 SDK 一期形态
SESSION_DELTA / BADGE_UPDATE 未实现 会话列表与角标无推送,SDK 在收到新消息后按需重拉 PULL_SESSION_LIST;ConversationsUpdated 事件语义不变,将来服务端补齐推送后上层无感
SYNC_COMPLETE(上行) 未实现 同步完成以本端"批次取空"判定,不上报
REDIRECT 未实现 不处理重定向;SHARD_MOVED 走通用退避重连
TYPING / PRESENCE_SUB / REACTION_* / RTC_SIGNAL 未实现 一期不提供对应 API(提供了也是空转)
READ_SYNC(0x0406) 契约已定,服务端未实现 一期没有接收方,见下方说明。SDK 不处理该帧(Decoded::Ignored),跨设备已读靠下次 PULL_SESSION_LIST 收敛
其余(AUTH / PULL_MAILBOX / SEND / PUSH / HISTORY / SESSION_LIST / MARK_READ / MEMBERS / ROOM_*) 已实现 全量支持

把"服务端没实现"写进 SDK 文档而不是留给上层试,是因为这类能力缺失 在联调时表现为"调了没反应",最容易被误判成 SDK 缺陷。

8.1 READ_SYNC 为什么定了契约却不实现

READ_SYNC 的语义是"推给该用户的其他在线设备"。而一期 gateway/frames.rs 的顶号逻辑是 evict_device(uid, keep)——把该 user_id 下除本连接外的全部连接踢掉,注释写明"一期 user 与 device 合一,按 uid 摘"(§15.3)。

因此一期同一用户不可能有两个设备同时在线,这条帧永远没有接收方。

现在把它实现出来,得到的是一段永远不执行、也永远没被验证过的推送路径。 那比不实现更危险:代码里看起来是"已实现",等真上多设备时第一次执行 就在生产环境。本项目已经吃过一次同类亏——seq.rs::complete() 的注释 写着"热路径不扫描"而代码在扫,正因为没人验证过注释描述的那条路径。

前置条件是多设备本身,不是这条帧:连接注册要从 uid 改成 (uid, device_id)、顶号语义从"顶用户"改成"顶同设备"、AUTH.device_id 要真正参与路由。那是独立的一块工作,且会牵动 §15.3 的契约。

在此之前,跨设备已读的收敛路径是:设备 B 下次 PULL_SESSION_LIST 时 拿回服务端的权威水位 read_conversation_seq(user 级共享的 read:{user},设备 A 标的已读对 B 天然生效),合入本地后按定义式重算 (§6.1)。只是不实时——READ_SYNC 要解决的正是这个实时性, 而不是正确性。

9. 里程碑与验收判据

一次交付完整 SDK,内部按风险从高到低排序推进(前两项先做,是因为它们 一旦不成立会推翻后面全部工作量估计):

# 工作项 状态 验收判据
1 port 层接口 + 测试实现(内存回环/内存表/确定性时钟) 已完成 核心可在无网络无磁盘下跑完整状态机
2 公开面定型 + C ABI 绑定 已完成 qim-sdk-ffi 出 cdylib/staticlib;examples/demo.c 连真实服务端跑通全链路
3 native 平台实现:TCP+TLS、心跳、重连退避、顶号 已完成 退避带抖动且不同设备错开(有用例);KICKED 是终态不自动重连
4 本地库七表 + 前向迁移(SQLite) 已完成 重置必然连同游标;local_pending 必然保留(两条用例)
5 状态机自 loadgen 抽出 + 落库 已完成 原状态机用例全部迁入并通过
6 登录同步 + 对账 + REBUILD 已完成 Reconciling 是必经态;对账发生在去重之前(有用例)
7 不变量测试 已完成 §5 九条逐条有测试,共 17 项
8 UniFFI 绑定(Swift/Kotlin/Python) 已完成 qim-sdk-uniffi + scripts/gen-bindings.sh 出三端产物;Python 绑定连真实服务端跑通收发/自定义类型/未读/对账(examples/demo.py);核心一行未改
9 loadgen 改依赖 SDK + SDK 回归泳道 已完成 loadgen 三份重复实现(状态机/游标/去重,982 行)已删;泳道三条不变量进门禁;15,000 msg/s 不回归(实测 15,011 msg/s,端到端 P99 166 ms)
10 契约一致性测试 待做 SDK 与服务端跑同一组编解码用例向量(§5.3.5 同款)
11 可移植性守门 已完成 cargo check -p qim-sdk --no-default-features --target wasm32-unknown-unknown 已通过

功能覆盖(PC 端实测逐项)

功能 SDK 桌面端 实测
登录 / 同步 / 对账 / 在线 ✓ ✓ 状态迁移与 §7 一致
发送 / SEND_ACK / 幂等重发 ✓ ✓ 含重启后不撞幂等键
实时接收(PUSH_EVENTS) ✓ ✓ 双客户端互通
离线同步(MAILBOX_BATCH) ✓ ✓ 登录时补齐
会话列表(网络) ✓ /list 未读数正确
会话列表(本地) ✓ /local 冷启动即可渲染
会话内历史(本地) ✓ 启动即载入 重启后仍在(SQLite)
历史翻页(PULL_HISTORY) ✓ /history 结果落库、按会话身份去重
已读水位 ✓ /read 只进不退
未确认列表 / 手动重发 ✓ /pending /resend
顶号 ✓ ✓ 终态、不自动重连
重连退避 ✓ ✓ 带抖动
群聊消息收发 ✓ /gmsg 群会话只是另一个 conversation_id,路径与单聊相同
群成员分页 ✓ /members PULL_MEMBERS → MembersUpdated
聊天室 ✓ /join /rmsg ROOM_* → RoomEvents;不落库、不计未读
撤回 / 编辑 / 表情回应 / 输入中 ✗ ✗ 服务端亦未实现(§8)
媒体 / 离线推送 / E2EE ✗ ✗ 服务端未实现

服务端已实现的帧,SDK 已全部接入。仍缺的能力都缺在服务端(§8): 撤回 / 编辑 / 表情回应 / 输入中 / 媒体 / 离线推送 / E2EE, 以及 READ_SYNC(帧已进契约,但一期 user/device 合一、顶号会踢掉同 uid 的其他连接,没有第二台设备能收,故实现推迟,见 §8.1)。

已交付的 crate

qim-sdk        纯 Rust 核心(api/client/state/cursor/dedup/wire/port + native 实现)
qim-sdk-uniffi Swift / Kotlin / Python 绑定(scripts/gen-bindings.sh)
qim-sdk-ffi    C ABI 绑定:cdylib + staticlib + 可运行的 examples/demo.c
qim-desktop    桌面客户端:SDK 的最小完整宿主,**只依赖 SDK**

qim-desktop 的"只依赖 SDK"是纪律不是巧合:凡是它写起来别扭的地方 都是公开面的缺陷。首次接入即抓到三处(会话 id 未导出、正文未接上、 client_message_id 跨重启重复),见 ADR-0010 的后果小节。

第 1、2 项排在最前不是因为简单,而是因为它们一旦不成立会推翻后面 全部工作量估计:port 接口划错会渗进每个 async fn 签名, 公开面越界会逼出手写胶水。第 10 项是这两条的持续守门—— ADR-0010 §3 的可移植约束靠 CI 断言而不是靠自觉, 否则第一个 std::thread::spawn 混进核心时没人会发现。

总验收:新增门禁 6「SDK 端到端」——SDK 对真实四服务跑通登录、收发、 离线同步、对账、翻页、REBUILD,与 --e2e 同为逐项断言。

10. 待裁决(不阻塞开工)

议题 何时必须定
上层应用语言 只影响先打开哪个生成器目标(UniFFI / C ABI / wasm),核心与公开面不受影响;里程碑 2 之前定即可
本地库落盘加密与密钥托管 ADR-0011,移动端立项前必须定
浏览器端的 Transport(WebSocket) 与 LocalStore(IndexedDB) 实现 浏览器端立项时;接口一期已定,届时只补实现

不在此表内的事:加一门语言、加一个平台。它们已被 ADR-0010 §2/§3 化解为「加一个生成器目标」与「加一组 port 实现」,不构成待裁决议题—— 这正是"一个通用 SDK"要买到的东西。