# Cherry Studio 局域网传输协议规范 > 版本: 1.0 > 最后更新: 2025-12 本文档定义了 Cherry Studio 桌面客户端(Electron)与移动端(Expo)之间的局域网文件传输协议。 --- ## 目录 1. [协议概述](#1-协议概述) 2. [服务发现(Bonjour/mDNS)](#2-服务发现bonjourmdns) 3. [TCP 连接与握手](#3-tcp-连接与握手) 4. [消息格式规范](#4-消息格式规范) 5. [文件传输协议](#5-文件传输协议) 6. [心跳与连接保活](#6-心跳与连接保活) 7. [错误处理](#7-错误处理) 8. [常量与配置](#8-常量与配置) 9. [完整时序图](#9-完整时序图) 10. [移动端实现指南](#10-移动端实现指南) --- ## 1. 协议概述 ### 1.1 架构角色 | 角色 | 平台 | 职责 | | -------------------- | --------------- | ---------------------------- | | **Client(客户端)** | Electron 桌面端 | 扫描服务、发起连接、发送文件 | | **Server(服务端)** | Expo 移动端 | 发布服务、接受连接、接收文件 | ### 1.2 协议栈(v1) ``` ┌─────────────────────────────────────┐ │ 应用层(文件传输) │ ├─────────────────────────────────────┤ │ 消息层(控制: JSON \n) │ │ (数据: 二进制帧) │ ├─────────────────────────────────────┤ │ 传输层(TCP) │ ├─────────────────────────────────────┤ │ 发现层(Bonjour/mDNS) │ └─────────────────────────────────────┘ ``` ### 1.3 通信流程概览 ``` 1. 服务发现 → 移动端发布 mDNS 服务,桌面端扫描发现 2. TCP 握手 → 建立连接,交换设备信息(`version=1`) 3. 文件传输 → 控制消息使用 JSON,`file_chunk` 使用二进制帧分块传输 4. 连接保活 → ping/pong 心跳 ``` --- ## 2. 服务发现(Bonjour/mDNS) ### 2.1 服务类型 | 属性 | 值 | | ------------ | -------------------- | | 服务类型 | `cherrystudio` | | 协议 | `tcp` | | 完整服务标识 | `_cherrystudio._tcp` | ### 2.2 服务发布(移动端) 移动端需要通过 mDNS/Bonjour 发布服务: ```typescript // 服务发布参数 { name: "Cherry Studio Mobile", // 设备名称 type: "cherrystudio", // 服务类型 protocol: "tcp", // 协议 port: 53317, // TCP 监听端口 txt: { // TXT 记录(可选) version: "1", platform: "ios" // 或 "android" } } ``` ### 2.3 服务发现(桌面端) 桌面端扫描并解析服务信息: ```typescript // 发现的服务信息结构 type LocalTransferPeer = { id: string; // 唯一标识符 name: string; // 设备名称 host?: string; // 主机名 fqdn?: string; // 完全限定域名 port?: number; // TCP 端口 type?: string; // 服务类型 protocol?: "tcp" | "udp"; // 协议 addresses: string[]; // IP 地址列表 txt?: Record; // TXT 记录 updatedAt: number; // 发现时间戳 }; ``` ### 2.4 IP 地址选择策略 当服务有多个 IP 地址时,优先选择 IPv4: ```typescript // 优先选择 IPv4 地址 const preferredAddress = addresses.find((addr) => isIPv4(addr)) || addresses[0]; ``` --- ## 3. TCP 连接与握手 ### 3.1 连接建立 1. 客户端使用发现的 `host:port` 建立 TCP 连接 2. 连接成功后立即发送握手消息 3. 等待服务端响应握手确认 ### 3.2 握手消息(协议版本 v1) #### Client → Server: `handshake` ```typescript type LanTransferHandshakeMessage = { type: "handshake"; deviceName: string; // 设备名称 version: string; // 协议版本,当前为 "1" platform?: string; // 平台:'darwin' | 'win32' | 'linux' appVersion?: string; // 应用版本 }; ``` **示例:** ```json { "type": "handshake", "deviceName": "Cherry Studio 1.7.2", "version": "1", "platform": "darwin", "appVersion": "1.7.2" } ``` ### 4. 消息格式规范(混合协议) v1 使用"控制 JSON + 二进制数据帧"的混合协议(流式传输模式,无 per-chunk ACK): - **控制消息**(握手、心跳、file_start/ack、file_end、file_complete):UTF-8 JSON,`\n` 分隔 - **数据消息**(`file_chunk`):二进制帧,使用 Magic + 总长度做分帧,不经 Base64 ### 4.1 控制消息编码(JSON + `\n`) | 属性 | 规范 | | ---------- | ------------ | | 编码格式 | UTF-8 | | 序列化格式 | JSON | | 消息分隔符 | `\n`(0x0A) | ```typescript function sendControlMessage(socket: Socket, message: object): void { socket.write(`${JSON.stringify(message)}\n`); } ``` ### 4.2 `file_chunk` 二进制帧格式 为解决 TCP 分包/粘包并消除 Base64 开销,`file_chunk` 采用带总长度的二进制帧: ``` ┌──────────┬──────────┬────────┬───────────────┬──────────────┬────────────┬───────────┐ │ Magic │ TotalLen │ Type │ TransferId Len│ TransferId │ ChunkIdx │ Data │ │ 0x43 0x53│ (4B BE) │ 0x01 │ (2B BE) │ (UTF-8) │ (4B BE) │ (raw) │ └──────────┴──────────┴────────┴───────────────┴──────────────┴────────────┴───────────┘ ``` | 字段 | 大小 | 说明 | | -------------- | ---- | ------------------------------------------- | | Magic | 2B | 常量 `0x43 0x53` ("CS"), 用于区分 JSON 消息 | | TotalLen | 4B | Big-endian,帧总长度(不含 Magic/TotalLen) | | Type | 1B | `0x01` 代表 `file_chunk` | | TransferId Len | 2B | Big-endian,transferId 字符串长度 | | TransferId | nB | UTF-8 transferId(长度由上一字段给出) | | ChunkIdx | 4B | Big-endian,块索引,从 0 开始 | | Data | mB | 原始文件二进制数据(未编码) | > 计算帧总长度:`TotalLen = 1 + 2 + transferIdLen + 4 + dataLen`(即 Type~Data 的长度和)。 ### 4.3 消息解析策略 1. 读取 socket 数据到缓冲区; 2. 若前两字节为 `0x43 0x53` → 按二进制帧解析: - 至少需要 6 字节头(Magic + TotalLen),不足则等待更多数据 - 读取 `TotalLen` 判断帧整体长度,缓冲区不足则继续等待 - 解析 Type/TransferId/ChunkIdx/Data,并传入文件接收逻辑 3. 否则若首字节为 `{` → 按 JSON + `\n` 解析控制消息 4. 其它数据丢弃 1 字节并继续循环,避免阻塞。 ### 4.4 消息类型汇总(v1) | 类型 | 方向 | 编码 | 用途 | | ---------------- | --------------- | -------- | ----------------------- | | `handshake` | Client → Server | JSON+\n | 握手请求(version=1) | | `handshake_ack` | Server → Client | JSON+\n | 握手响应 | | `ping` | Client → Server | JSON+\n | 心跳请求 | | `pong` | Server → Client | JSON+\n | 心跳响应 | | `file_start` | Client → Server | JSON+\n | 开始文件传输 | | `file_start_ack` | Server → Client | JSON+\n | 文件传输确认 | | `file_chunk` | Client → Server | 二进制帧 | 文件数据块(无 Base64,流式无 per-chunk ACK) | | `file_end` | Client → Server | JSON+\n | 文件传输结束 | | `file_complete` | Server → Client | JSON+\n | 传输完成结果 | ``` {"type":"message_type",...其他字段...}\n ``` ### 4.3 消息发送 ```typescript function sendMessage(socket: Socket, message: object): void { const payload = JSON.stringify(message); socket.write(`${payload}\n`); } ``` ### 4.4 消息接收与解析 ```typescript let buffer = ""; socket.on("data", (chunk: Buffer) => { buffer += chunk.toString("utf8"); let newlineIndex = buffer.indexOf("\n"); while (newlineIndex !== -1) { const line = buffer.slice(0, newlineIndex).trim(); buffer = buffer.slice(newlineIndex + 1); if (line.length > 0) { const message = JSON.parse(line); handleMessage(message); } newlineIndex = buffer.indexOf("\n"); } }); ``` ### 4.5 消息类型汇总 | 类型 | 方向 | 用途 | | ---------------- | --------------- | ------------ | | `handshake` | Client → Server | 握手请求 | | `handshake_ack` | Server → Client | 握手响应 | | `ping` | Client → Server | 心跳请求 | | `pong` | Server → Client | 心跳响应 | | `file_start` | Client → Server | 开始文件传输 | | `file_start_ack` | Server → Client | 文件传输确认 | | `file_chunk` | Client → Server | 文件数据块(流式,无 per-chunk ACK) | | `file_end` | Client → Server | 文件传输结束 | | `file_complete` | Server → Client | 传输完成结果 | --- ## 5. 文件传输协议 ### 5.1 传输流程 ``` Client (Sender) Server (Receiver) | | |──── 1. file_start ────────────────>| | (文件元数据) | | | |<─── 2. file_start_ack ─────────────| | (接受/拒绝) | | | |══════ 循环发送数据块(流式,无 ACK) ═════| | | |──── 3. file_chunk [0] ────────────>| | | |──── 3. file_chunk [1] ────────────>| | | | ... 重复直到所有块发送完成 ... | | | |══════════════════════════════════════ | | |──── 5. file_end ──────────────────>| | (所有块已发送) | | | |<─── 6. file_complete ──────────────| | (最终结果) | ``` ### 5.2 消息定义 #### 5.2.1 `file_start` - 开始传输 **方向:** Client → Server ```typescript type LanTransferFileStartMessage = { type: "file_start"; transferId: string; // UUID,唯一传输标识 fileName: string; // 文件名(含扩展名) fileSize: number; // 文件总字节数 mimeType: string; // MIME 类型 checksum: string; // 整个文件的 SHA-256 哈希(hex) totalChunks: number; // 总数据块数 chunkSize: number; // 每块大小(字节) }; ``` **示例:** ```json { "type": "file_start", "transferId": "550e8400-e29b-41d4-a716-446655440000", "fileName": "backup.zip", "fileSize": 524288000, "mimeType": "application/zip", "checksum": "a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456", "totalChunks": 8192, "chunkSize": 65536 } ``` #### 5.2.2 `file_start_ack` - 传输确认 **方向:** Server → Client ```typescript type LanTransferFileStartAckMessage = { type: "file_start_ack"; transferId: string; // 对应的传输 ID accepted: boolean; // 是否接受传输 message?: string; // 拒绝原因 }; ``` **接受示例:** ```json { "type": "file_start_ack", "transferId": "550e8400-e29b-41d4-a716-446655440000", "accepted": true } ``` **拒绝示例:** ```json { "type": "file_start_ack", "transferId": "550e8400-e29b-41d4-a716-446655440000", "accepted": false, "message": "Insufficient storage space" } ``` #### 5.2.3 `file_chunk` - 数据块 **方向:** Client → Server(**二进制帧**,见 4.2) - 不再使用 JSON/`\n`,也不再使用 Base64 - 帧结构:`Magic` + `TotalLen` + `Type` + `TransferId` + `ChunkIdx` + `Data` - `Type` 固定 `0x01`,`Data` 为原始文件二进制数据 - 传输完整性依赖 `file_start.checksum`(全文件 SHA-256);分块校验和可选,不在帧中发送 #### 5.2.4 `file_chunk_ack` - 数据块确认(v1 流式不使用) v1 采用流式传输,不发送 per-chunk ACK。本节类型仅保留作为向后兼容参考,实际不会发送。 #### 5.2.5 `file_end` - 传输结束 **方向:** Client → Server ```typescript type LanTransferFileEndMessage = { type: "file_end"; transferId: string; // 传输 ID }; ``` **示例:** ```json { "type": "file_end", "transferId": "550e8400-e29b-41d4-a716-446655440000" } ``` #### 5.2.6 `file_complete` - 传输完成 **方向:** Server → Client ```typescript type LanTransferFileCompleteMessage = { type: "file_complete"; transferId: string; // 传输 ID success: boolean; // 是否成功 filePath?: string; // 保存路径(成功时) error?: string; // 错误信息(失败时) }; ``` **成功示例:** ```json { "type": "file_complete", "transferId": "550e8400-e29b-41d4-a716-446655440000", "success": true, "filePath": "/storage/emulated/0/Documents/backup.zip" } ``` **失败示例:** ```json { "type": "file_complete", "transferId": "550e8400-e29b-41d4-a716-446655440000", "success": false, "error": "File checksum verification failed" } ``` ### 5.3 校验和算法 #### 整个文件校验和(保持不变) ```typescript async function calculateFileChecksum(filePath: string): Promise { const hash = crypto.createHash("sha256"); const stream = fs.createReadStream(filePath); for await (const chunk of stream) { hash.update(chunk); } return hash.digest("hex"); } ``` #### 数据块校验和 v1 默认 **不传输分块校验和**,依赖最终文件 checksum。若需要,可在应用层自定义(非协议字段)。 ### 5.4 校验流程 **发送端(Client):** 1. 发送前计算整个文件的 SHA-256 → `file_start.checksum` 2. 分块直接发送原始二进制(无 Base64) **接收端(Server):** 1. 收到 `file_chunk` 后直接使用二进制数据 2. 边收边落盘并增量计算 SHA-256(推荐) 3. 所有块接收完成后,计算/完成增量哈希,得到最终 SHA-256 4. 与 `file_start.checksum` 比对,结果写入 `file_complete` ### 5.5 数据块大小计算 ```typescript const CHUNK_SIZE = 512 * 1024; // 512KB const totalChunks = Math.ceil(fileSize / CHUNK_SIZE); // 最后一个块可能小于 CHUNK_SIZE const lastChunkSize = fileSize % CHUNK_SIZE || CHUNK_SIZE; ``` --- ## 6. 心跳与连接保活 ### 6.1 心跳消息 #### `ping` **方向:** Client → Server ```typescript type LanTransferPingMessage = { type: "ping"; payload?: string; // 可选载荷 }; ``` ```json { "type": "ping", "payload": "heartbeat" } ``` #### `pong` **方向:** Server → Client ```typescript type LanTransferPongMessage = { type: "pong"; received: boolean; // 确认收到 payload?: string; // 回传 ping 的载荷 }; ``` ```json { "type": "pong", "received": true, "payload": "heartbeat" } ``` ### 6.2 心跳策略 - 握手成功后立即发送一次 `ping` 验证连接 - 可选:定期发送心跳保持连接活跃 - `pong` 应返回 `ping` 中的 `payload`(可选) --- ## 7. 错误处理 ### 7.1 超时配置 | 操作 | 超时时间 | 说明 | | ---------- | -------- | --------------------- | | TCP 连接 | 10 秒 | 连接建立超时 | | 握手等待 | 10 秒 | 等待 `handshake_ack` | | 传输完成 | 60 秒 | 等待 `file_complete` | ### 7.2 错误场景处理 | 场景 | Client 处理 | Server 处理 | | --------------- | ------------------ | ---------------------- | | TCP 连接失败 | 通知 UI,允许重试 | - | | 握手超时 | 断开连接,通知 UI | 关闭 socket | | 握手被拒绝 | 显示拒绝原因 | - | | 数据块处理失败 | 中止传输,清理状态 | 清理临时文件 | | 连接意外断开 | 清理状态,通知 UI | 清理临时文件 | | 存储空间不足 | - | 发送 `accepted: false` | ### 7.3 资源清理 **Client 端:** ```typescript function cleanup(): void { // 1. 销毁文件读取流 if (readStream) { readStream.destroy(); } // 2. 清理传输状态 activeTransfer = undefined; // 3. 关闭 socket(如需要) socket?.destroy(); } ``` **Server 端:** ```typescript function cleanup(): void { // 1. 关闭文件写入流 if (writeStream) { writeStream.end(); } // 2. 删除未完成的临时文件 if (tempFilePath) { fs.unlinkSync(tempFilePath); } // 3. 清理传输状态 activeTransfer = undefined; } ``` --- ## 8. 常量与配置 ### 8.1 协议常量 ```typescript // 协议版本(v1 = 控制 JSON + 二进制 chunk + 流式传输) export const LAN_TRANSFER_PROTOCOL_VERSION = "1"; // 服务发现 export const LAN_TRANSFER_SERVICE_TYPE = "cherrystudio"; export const LAN_TRANSFER_SERVICE_FULL_NAME = "_cherrystudio._tcp"; // TCP 端口 export const LAN_TRANSFER_TCP_PORT = 53317; // 文件传输(与二进制帧一致) export const LAN_TRANSFER_CHUNK_SIZE = 512 * 1024; // 512KB export const LAN_TRANSFER_GLOBAL_TIMEOUT_MS = 10 * 60 * 1000; // 10 分钟 // 超时设置 export const LAN_TRANSFER_HANDSHAKE_TIMEOUT_MS = 10_000; // 10秒 export const LAN_TRANSFER_CHUNK_TIMEOUT_MS = 30_000; // 30秒 export const LAN_TRANSFER_COMPLETE_TIMEOUT_MS = 60_000; // 60秒 ``` ### 8.2 支持的文件类型 当前仅支持 ZIP 文件: ```typescript export const LAN_TRANSFER_ALLOWED_EXTENSIONS = [".zip"]; export const LAN_TRANSFER_ALLOWED_MIME_TYPES = [ "application/zip", "application/x-zip-compressed", ]; ``` --- ## 9. 完整时序图 ### 9.1 完整传输流程(v1,流式传输) ``` ┌─────────┐ ┌─────────┐ ┌─────────┐ │ Renderer│ │ Main │ │ Mobile │ │ (UI) │ │ Process │ │ Server │ └────┬────┘ └────┬────┘ └────┬────┘ │ │ │ │ ════════════ 服务发现阶段 ════════════ │ │ │ │ │ startScan() │ │ │────────────────────────────────────>│ │ │ │ mDNS browse │ │ │ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─>│ │ │ │ │ │<─ ─ ─ service discovered ─ ─ ─ ─ ─ ─│ │ │ │ │<────── onServicesUpdated ───────────│ │ │ │ │ │ ════════════ 握手连接阶段 ════════════ │ │ │ │ │ connect(peer) │ │ │────────────────────────────────────>│ │ │ │──────── TCP Connect ───────────────>│ │ │ │ │ │──────── handshake ─────────────────>│ │ │ │ │ │<─────── handshake_ack ──────────────│ │ │ │ │ │──────── ping ──────────────────────>│ │ │<─────── pong ───────────────────────│ │ │ │ │<────── connect result ──────────────│ │ │ │ │ │ ════════════ 文件传输阶段 ════════════ │ │ │ │ │ sendFile(path) │ │ │────────────────────────────────────>│ │ │ │──────── file_start ────────────────>│ │ │ │ │ │<─────── file_start_ack ─────────────│ │ │ │ │ │ │ │ │══════ 循环发送数据块 ═══════════════│ │ │ │ │ │──────── file_chunk[0] (binary) ────>│ │<────── progress event ──────────────│ │ │ │ │ │ │──────── file_chunk[1] (binary) ────>│ │<────── progress event ──────────────│ │ │ │ │ │ │ ... 重复 ... │ │ │ │ │ │══════════════════════════════════════│ │ │ │ │ │──────── file_end ──────────────────>│ │ │ │ │ │<─────── file_complete ──────────────│ │ │ │ │<────── complete event ──────────────│ │ │<────── sendFile result ─────────────│ │ │ │ │ ``` --- ## 10. 移动端实现指南(v1 要点) ### 10.1 必须实现的功能 1. **mDNS 服务发布** - 发布 `_cherrystudio._tcp` 服务 - 提供 TCP 端口号 `53317` - 可选:TXT 记录(版本、平台信息) 2. **TCP 服务端** - 监听指定端口 - 支持单连接或多连接 3. **消息解析** - 控制消息:UTF-8 + `\n` JSON - 数据消息:二进制帧(Magic+TotalLen 分帧) 4. **握手处理** - 验证 `handshake` 消息 - 发送 `handshake_ack` 响应 - 响应 `ping` 消息 5. **文件接收(流式模式)** - 解析 `file_start`,准备接收 - 接收 `file_chunk` 二进制帧,直接写入文件/缓冲并增量哈希 - v1 不发送 per-chunk ACK(流式传输) - 处理 `file_end`,完成增量哈希并校验 checksum - 发送 `file_complete` 结果 ### 10.2 推荐的库 **React Native / Expo:** - mDNS: `react-native-zeroconf` 或 `@homielab/react-native-bonjour` - TCP: `react-native-tcp-socket` - Crypto: `expo-crypto` 或 `react-native-quick-crypto` ### 10.3 接收端伪代码 ```typescript class FileReceiver { private transfer?: { id: string; fileName: string; fileSize: number; checksum: string; totalChunks: number; receivedChunks: number; tempPath: string; // v1: 边收边写文件,避免大文件 OOM // stream: FileSystem writable stream (平台相关封装) }; handleMessage(message: any) { switch (message.type) { case "handshake": this.handleHandshake(message); break; case "ping": this.sendPong(message); break; case "file_start": this.handleFileStart(message); break; // v1: file_chunk 为二进制帧,不再走 JSON 分支 case "file_end": this.handleFileEnd(message); break; } } handleFileStart(msg: LanTransferFileStartMessage) { // 1. 检查存储空间 // 2. 创建临时文件 // 3. 初始化传输状态 // 4. 发送 file_start_ack } // v1: 二进制帧处理在 socket data 流中解析,随后调用 handleBinaryFileChunk handleBinaryFileChunk(transferId: string, chunkIndex: number, data: Buffer) { // 直接使用二进制数据,按 chunkSize/lastChunk 计算长度 // 写入文件流并更新增量 SHA-256 this.transfer.receivedChunks++; // v1: 流式传输,不发送 per-chunk ACK } handleFileEnd(msg: LanTransferFileEndMessage) { // 1. 合并所有数据块 // 2. 验证完整文件 checksum // 3. 写入最终位置 // 4. 发送 file_complete } } ``` --- ## 附录 A:TypeScript 类型定义 完整的类型定义位于 `src/types/lanTransfer.ts`: ```typescript // 握手消息 export interface LanTransferHandshakeMessage { type: "handshake"; deviceName: string; version: string; platform?: string; appVersion?: string; } export interface LanTransferHandshakeAckMessage { type: "handshake_ack"; accepted: boolean; message?: string; } // 心跳消息 export interface LanTransferPingMessage { type: "ping"; payload?: string; } export interface LanTransferPongMessage { type: "pong"; received: boolean; payload?: string; } // 文件传输消息 (Client -> Server) export interface LanTransferFileStartMessage { type: "file_start"; transferId: string; fileName: string; fileSize: number; mimeType: string; checksum: string; totalChunks: number; chunkSize: number; } export interface LanTransferFileChunkMessage { type: "file_chunk"; transferId: string; chunkIndex: number; data: string; // Base64 encoded (v1: 二进制帧模式下不使用) } export interface LanTransferFileEndMessage { type: "file_end"; transferId: string; } // 文件传输响应消息 (Server -> Client) export interface LanTransferFileStartAckMessage { type: "file_start_ack"; transferId: string; accepted: boolean; message?: string; } // v1 流式不发送 per-chunk ACK,以下类型仅用于向后兼容参考 export interface LanTransferFileChunkAckMessage { type: "file_chunk_ack"; transferId: string; chunkIndex: number; received: boolean; error?: string; } export interface LanTransferFileCompleteMessage { type: "file_complete"; transferId: string; success: boolean; filePath?: string; error?: string; } // 常量 export const LAN_TRANSFER_TCP_PORT = 53317; export const LAN_TRANSFER_CHUNK_SIZE = 512 * 1024; export const LAN_TRANSFER_CHUNK_TIMEOUT_MS = 30_000; ``` --- ## 附录 B:版本历史 | 版本 | 日期 | 变更 | | ---- | ------- | ---------------------------------------- | | 1.0 | 2025-12 | 初始发布版本,支持二进制帧格式与流式传输 |