From 37db8f1c9cf0ee31753c1313ba1e96d220eff45d Mon Sep 17 00:00:00 2001 From: eeee0717 Date: Wed, 17 Dec 2025 20:22:15 +0800 Subject: [PATCH 1/6] refactor: change qrcode landrop to lantransfer --- docs/zh/references/lan-transfer-protocol.md | 987 ++++++++++++++++++ package.json | 1 + packages/shared/IpcChannel.ts | 15 +- packages/shared/config/types.ts | 193 ++++ src/main/index.ts | 6 + src/main/ipc.ts | 17 + src/main/services/BackupManager.ts | 49 + src/main/services/LocalTransferService.ts | 207 ++++ .../lanTransfer/LanTransferClientService.ts | 486 +++++++++ .../__tests__/binaryProtocol.test.ts | 89 ++ .../__tests__/handlers/connection.test.ts | 162 +++ .../__tests__/handlers/fileTransfer.test.ts | 152 +++ .../__tests__/responseManager.test.ts | 177 ++++ .../services/lanTransfer/binaryProtocol.ts | 63 ++ .../lanTransfer/handlers/connection.ts | 151 +++ .../lanTransfer/handlers/fileTransfer.ts | 258 +++++ .../services/lanTransfer/handlers/index.ts | 22 + src/main/services/lanTransfer/index.ts | 21 + .../services/lanTransfer/responseManager.ts | 144 +++ src/main/services/lanTransfer/types.ts | 65 ++ src/preload/index.ts | 43 +- .../Popups/ExportToPhoneLanPopup.tsx | 553 ---------- .../Popups/LanTransferPopup/LanDeviceCard.tsx | 97 ++ .../LanTransferPopup/ProgressIndicator.tsx | 55 + .../Popups/LanTransferPopup/hook.ts | 397 +++++++ .../Popups/LanTransferPopup/index.tsx | 37 + .../Popups/LanTransferPopup/popup.tsx | 88 ++ .../Popups/LanTransferPopup/types.ts | 84 ++ src/renderer/src/i18n/locales/en-us.json | 31 + src/renderer/src/i18n/locales/zh-cn.json | 31 + src/renderer/src/i18n/locales/zh-tw.json | 31 + src/renderer/src/i18n/translate/de-de.json | 31 + src/renderer/src/i18n/translate/el-gr.json | 31 + src/renderer/src/i18n/translate/es-es.json | 31 + src/renderer/src/i18n/translate/fr-fr.json | 31 + src/renderer/src/i18n/translate/ja-jp.json | 31 + src/renderer/src/i18n/translate/pt-pt.json | 31 + src/renderer/src/i18n/translate/ru-ru.json | 31 + .../settings/DataSettings/DataSettings.tsx | 5 +- yarn.lock | 46 + 40 files changed, 4422 insertions(+), 558 deletions(-) create mode 100644 docs/zh/references/lan-transfer-protocol.md create mode 100644 src/main/services/LocalTransferService.ts create mode 100644 src/main/services/lanTransfer/LanTransferClientService.ts create mode 100644 src/main/services/lanTransfer/__tests__/binaryProtocol.test.ts create mode 100644 src/main/services/lanTransfer/__tests__/handlers/connection.test.ts create mode 100644 src/main/services/lanTransfer/__tests__/handlers/fileTransfer.test.ts create mode 100644 src/main/services/lanTransfer/__tests__/responseManager.test.ts create mode 100644 src/main/services/lanTransfer/binaryProtocol.ts create mode 100644 src/main/services/lanTransfer/handlers/connection.ts create mode 100644 src/main/services/lanTransfer/handlers/fileTransfer.ts create mode 100644 src/main/services/lanTransfer/handlers/index.ts create mode 100644 src/main/services/lanTransfer/index.ts create mode 100644 src/main/services/lanTransfer/responseManager.ts create mode 100644 src/main/services/lanTransfer/types.ts delete mode 100644 src/renderer/src/components/Popups/ExportToPhoneLanPopup.tsx create mode 100644 src/renderer/src/components/Popups/LanTransferPopup/LanDeviceCard.tsx create mode 100644 src/renderer/src/components/Popups/LanTransferPopup/ProgressIndicator.tsx create mode 100644 src/renderer/src/components/Popups/LanTransferPopup/hook.ts create mode 100644 src/renderer/src/components/Popups/LanTransferPopup/index.tsx create mode 100644 src/renderer/src/components/Popups/LanTransferPopup/popup.tsx create mode 100644 src/renderer/src/components/Popups/LanTransferPopup/types.ts diff --git a/docs/zh/references/lan-transfer-protocol.md b/docs/zh/references/lan-transfer-protocol.md new file mode 100644 index 000000000..e75b114d8 --- /dev/null +++ b/docs/zh/references/lan-transfer-protocol.md @@ -0,0 +1,987 @@ +# Cherry Studio 局域网传输协议规范 + +> 版本: 3.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 协议栈(v3) + +``` +┌─────────────────────────────────────┐ +│ 应用层(文件传输) │ +├─────────────────────────────────────┤ +│ 消息层(控制: JSON \n) │ +│ (数据: 二进制帧) │ +│ v3: 流式传输,无逐块确认 │ +├─────────────────────────────────────┤ +│ 传输层(TCP) │ +├─────────────────────────────────────┤ +│ 发现层(Bonjour/mDNS) │ +└─────────────────────────────────────┘ +``` + +### 1.3 通信流程概览 + +``` +1. 服务发现 → 移动端发布 mDNS 服务,桌面端扫描发现 +2. TCP 握手 → 建立连接,交换设备信息(`version=3`) +3. 文件传输 → 控制消息使用 JSON,`file_chunk` 使用二进制帧连续发送(无需等待确认) +4. 连接保活 → ping/pong 心跳 +``` + +### 1.4 安全说明 + +该协议默认运行在**可信局域网**环境:协议本身不提供传输加密、身份鉴权或防中间人攻击能力。若需要在不可信网络使用,建议在握手阶段增加一次性配对码/Token,并在传输层使用 TLS 或对文件数据进行端到端加密。 + +--- + +## 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: "3", + 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 握手消息(协议版本 v3) + +#### Client → Server: `handshake` + +```typescript +type LanTransferHandshakeMessage = { + type: "handshake"; + deviceName: string; // 设备名称 + version: string; // 协议版本,当前为 "3" + platform?: string; // 平台:'darwin' | 'win32' | 'linux' + appVersion?: string; // 应用版本 +}; +``` + +**示例:** + +```json +{ + "type": "handshake", + "deviceName": "Cherry Studio 1.7.2", + "version": "3", + "platform": "darwin", + "appVersion": "1.7.2" +} +``` + +### 4. 消息格式规范(混合协议) + +v3 采用"控制 JSON + 二进制数据帧"的混合协议,支持流式传输: + +- **控制消息**(握手、心跳、file_start/ack、file_end、file_complete、file_cancel):UTF-8 JSON,`\n` 分隔 +- **数据消息**(`file_chunk`):二进制帧,使用 Magic + 总长度做分帧,连续发送无需等待确认 + +### 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 消息类型汇总(v3) + +| 类型 | 方向 | 编码 | 用途 | +| ---------------- | --------------- | -------- | ------------------------------ | +| `handshake` | Client → Server | JSON+\n | 握手请求(version=3) | +| `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 | 二进制帧 | 文件数据块(连续发送,无确认) | +| `file_end` | Client → Server | JSON+\n | 文件传输结束 | +| `file_complete` | Server → Client | JSON+\n | 传输完成结果 | +| `file_cancel` | Client → Server | JSON+\n | 取消传输 | + +``` +{"type":"message_type",...其他字段...}\n +``` + +### 4.5 消息发送 + +```typescript +function sendMessage(socket: Socket, message: object): void { + const payload = JSON.stringify(message); + socket.write(`${payload}\n`); +} +``` + +### 4.6 消息接收与解析(v3 混合协议) + +```typescript +const MAGIC = Buffer.from([0x43, 0x53]); // "CS" +let buffer = Buffer.alloc(0); + +socket.on("data", (chunk: Buffer) => { + buffer = Buffer.concat([buffer, chunk]); + + while (buffer.length > 0) { + // 检查是否为二进制帧(Magic: 0x43 0x53) + if (buffer.length >= 2 && buffer[0] === 0x43 && buffer[1] === 0x53) { + // 需要至少 6 字节头(Magic + TotalLen) + if (buffer.length < 6) break; + + const totalLen = buffer.readUInt32BE(2); + const frameLen = 6 + totalLen; // Magic(2) + TotalLen(4) + payload + + if (buffer.length < frameLen) break; // 等待更多数据 + + // 解析二进制帧 + const type = buffer[6]; + const transferIdLen = buffer.readUInt16BE(7); + const transferId = buffer.slice(9, 9 + transferIdLen).toString("utf8"); + const chunkIndex = buffer.readUInt32BE(9 + transferIdLen); + const data = buffer.slice(13 + transferIdLen, frameLen); + + handleBinaryChunk(transferId, chunkIndex, data); + buffer = buffer.slice(frameLen); + } + // 检查是否为 JSON 控制消息(以 '{' 开头) + else if (buffer[0] === 0x7b) { + // '{' = 0x7b + const newlineIndex = buffer.indexOf(0x0a); // '\n' = 0x0a + if (newlineIndex === -1) break; // 等待完整的 JSON 行 + + const line = buffer.slice(0, newlineIndex).toString("utf8").trim(); + buffer = buffer.slice(newlineIndex + 1); + + if (line.length > 0) { + const message = JSON.parse(line); + handleMessage(message); + } + } + // 其他数据丢弃 1 字节,继续解析 + else { + buffer = buffer.slice(1); + } + } +}); +``` + +--- + +## 5. 文件传输协议 + +### 5.1 传输流程(v3 流式传输) + +``` +Client (Sender) Server (Receiver) + | | + |──── 1. file_start ────────────────>| + | (文件元数据) | + | | + |<─── 2. file_start_ack ─────────────| + | (接受/拒绝) | + | | + |══════ 连续发送数据块(无确认)═══════| + | | + |──── 3. file_chunk [0] ────────────>| + |──── 3. file_chunk [1] ────────────>| + |──── 3. file_chunk [2] ────────────>| + | ... 连续发送所有块 ... | + |──── 3. file_chunk [N-1] ──────────>| + | | + |══════════════════════════════════════ + | | + |──── 4. file_end ──────────────────>| + | (所有块已发送) | + | | + |<─── 5. file_complete ──────────────| + | (校验和验证结果) | +``` + +> **v3 特性**: 数据块连续发送,无需等待每块确认。接收端在收到 `file_end` 后验证完整性,通过 `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": 1000, + "chunkSize": 524288 +} +``` + +#### 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_end` - 传输结束 + +**方向:** Client → Server + +```typescript +type LanTransferFileEndMessage = { + type: "file_end"; + transferId: string; // 传输 ID +}; +``` + +**示例:** + +```json +{ + "type": "file_end", + "transferId": "550e8400-e29b-41d4-a716-446655440000" +} +``` + +#### 5.2.5 `file_complete` - 传输完成 + +**方向:** Server → Client + +```typescript +type LanTransferFileCompleteMessage = { + type: "file_complete"; + transferId: string; // 传输 ID + success: boolean; // 是否成功 + filePath?: string; // 保存路径(成功时) + error?: string; // 错误信息(失败时) + // v3 新增字段 + errorCode?: + | "CHECKSUM_MISMATCH" + | "INCOMPLETE_TRANSFER" + | "DISK_ERROR" + | "CANCELLED"; + receivedChunks?: number; // 实际接收的数据块数量 + receivedBytes?: number; // 实际接收的字节数 +}; +``` + +**错误码说明:** + +| 错误码 | 说明 | +| --------------------- | -------------------------- | +| `CHECKSUM_MISMATCH` | 校验和不匹配 | +| `INCOMPLETE_TRANSFER` | 传输不完整,缺少数据块 | +| `DISK_ERROR` | 磁盘写入错误或存储空间不足 | +| `CANCELLED` | 传输被取消 | + +**成功示例:** + +```json +{ + "type": "file_complete", + "transferId": "550e8400-e29b-41d4-a716-446655440000", + "success": true, + "filePath": "/storage/emulated/0/Documents/backup.zip", + "receivedChunks": 1000, + "receivedBytes": 524288000 +} +``` + +**失败示例:** + +```json +{ + "type": "file_complete", + "transferId": "550e8400-e29b-41d4-a716-446655440000", + "success": false, + "error": "File checksum verification failed", + "errorCode": "CHECKSUM_MISMATCH", + "receivedChunks": 1000, + "receivedBytes": 524288000 +} +``` + +#### 5.2.6 `file_cancel` - 取消传输 + +**方向:** Client → Server + +```typescript +type LanTransferFileCancelMessage = { + type: "file_cancel"; + transferId: string; // 传输 ID + reason?: string; // 取消原因 +}; +``` + +**示例:** + +```json +{ + "type": "file_cancel", + "transferId": "550e8400-e29b-41d4-a716-446655440000", + "reason": "Cancelled by user" +} +``` + +### 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"); +} +``` + +#### 数据块校验和 + +v3 默认 **不传输分块校验和**,依赖最终文件 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` | +| 全局超时 | 10 分钟 | 整个文件传输超时 | + +### 7.2 错误场景处理 + +| 场景 | Client 处理 | Server 处理 | +| ------------ | ------------------------ | ------------------------------- | +| TCP 连接失败 | 通知 UI,允许重试 | - | +| 握手超时 | 断开连接,通知 UI | 关闭 socket | +| 握手被拒绝 | 显示拒绝原因 | - | +| 用户取消 | 发送 `file_cancel`,清理 | 清理临时文件 | +| 连接意外断开 | 清理状态,通知 UI | 清理临时文件 | +| 存储空间不足 | - | 发送 `accepted: false` | +| 校验和失败 | 显示错误信息 | 发送 `file_complete` 带错误码 | +| 数据块缺失 | 显示错误信息 | 发送 `INCOMPLETE_TRANSFER` 错误 | + +### 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 +// 协议版本(v3 = 控制 JSON + 二进制 chunk 流式传输) +export const LAN_TRANSFER_PROTOCOL_VERSION = "3"; + +// 服务发现 +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_MAX_FILE_SIZE = 500 * 1024 * 1024; // 500MB +export const LAN_TRANSFER_GLOBAL_TIMEOUT_MS = 10 * 60 * 1000; // 10 分钟 + +// 注意:接收端必须支持至少 512KB 的分片大小,否则会拒收并返回类似 +// "Chunk size 524288 exceeds limit 65536" 的错误。 + +// 超时设置 +export const LAN_TRANSFER_HANDSHAKE_TIMEOUT_MS = 10_000; // 10秒 +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 完整传输流程(v3,流式传输) + +``` +┌─────────┐ ┌─────────┐ ┌─────────┐ +│ Renderer│ │ Main │ │ Mobile │ +│ (UI) │ │ Process │ │ Server │ +└────┬────┘ └────┬────┘ └────┬────┘ + │ │ │ + │ ════════════ 服务发现阶段 ════════════ │ + │ │ │ + │ startScan() │ │ + │────────────────────────────────────>│ │ + │ │ mDNS browse │ + │ │ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─>│ + │ │ │ + │ │<─ ─ ─ service discovered ─ ─ ─ ─ ─ ─│ + │ │ │ + │<────── onServicesUpdated ───────────│ │ + │ │ │ + │ ════════════ 握手连接阶段 ════════════ │ + │ │ │ + │ connect(peer) │ │ + │────────────────────────────────────>│ │ + │ │──────── TCP Connect ───────────────>│ + │ │ │ + │ │──────── handshake (v=3) ───────────>│ + │ │ │ + │ │<─────── handshake_ack ──────────────│ + │ │ │ + │ │──────── ping ──────────────────────>│ + │ │<─────── pong ───────────────────────│ + │ │ │ + │<────── connect result ──────────────│ │ + │ │ │ + │ ════════════ 文件传输阶段 ════════════ │ + │ │ │ + │ sendFile(path) │ │ + │────────────────────────────────────>│ │ + │ │──────── file_start ────────────────>│ + │ │ │ + │ │<─────── file_start_ack ─────────────│ + │ │ │ + │ │══════ 连续发送数据块(无确认)═══════│ + │ │ │ + │ │──────── file_chunk[0] (binary) ────>│ + │ │──────── file_chunk[1] (binary) ────>│ + │ │──────── file_chunk[2] (binary) ────>│ + │<────── progress event ──────────────│ ... 连续发送 ... │ + │ │──────── file_chunk[N-1] (binary) ──>│ + │ │ │ + │ │══════════════════════════════════════│ + │ │ │ + │ │──────── file_end ──────────────────>│ + │ │ │ + │ │ (接收端验证 checksum) │ + │ │ │ + │ │<─────── file_complete ──────────────│ + │ │ │ + │<────── complete event ──────────────│ │ + │<────── sendFile result ─────────────│ │ + │ │ │ +``` + +> **v3 特性**: 数据块连续发送,不等待单个确认,大幅提高传输速度。接收端在 `file_end` 后统一验证完整性。 + +--- + +## 10. 移动端实现指南(v3 要点) + +### 10.1 必须实现的功能 + +1. **mDNS 服务发布** + + - 发布 `_cherrystudio._tcp` 服务 + - 提供 TCP 端口号 `53317` + - 可选:TXT 记录(版本、平台信息) + +2. **TCP 服务端** + + - 监听指定端口 + - 支持单连接或多连接 + +3. **消息解析** + + - 控制消息:UTF-8 + `\n` JSON + - 数据消息:二进制帧(Magic+TotalLen 分帧) + +4. **握手处理** + + - 验证 `handshake` 消息(version=3) + - 发送 `handshake_ack` 响应 + - 响应 `ping` 消息 + +5. **文件接收(v3 流式模式)** + + - 解析 `file_start`,准备接收 + - 接收 `file_chunk` 二进制帧,直接写入文件并增量计算哈希 + - **无需发送 `file_chunk_ack`**(v3 移除了逐块确认) + - 处理 `file_end`,完成增量哈希并校验 checksum + - 发送 `file_complete` 结果(包含 errorCode、receivedChunks、receivedBytes) + +6. **取消处理** + - 监听 `file_cancel` 消息 + - 清理临时文件和状态 + +### 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; + // v3: 边收边写文件,避免大文件 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; + // v3: file_chunk 为二进制帧,不走 JSON 分支 + case "file_end": + this.handleFileEnd(message); + break; + case "file_cancel": + this.handleFileCancel(message); + break; + } + } + + handleFileStart(msg: LanTransferFileStartMessage) { + // 1. 检查存储空间 + // 2. 创建临时文件 + // 3. 初始化传输状态 + // 4. 发送 file_start_ack + } + + // v3: 二进制帧处理在 socket data 流中解析,随后调用 handleBinaryFileChunk + handleBinaryFileChunk(transferId: string, chunkIndex: number, data: Buffer) { + // 直接使用二进制数据,按 chunkSize/lastChunk 计算长度 + // 写入文件流并更新增量 SHA-256 + this.transfer.receivedChunks++; + // v3: 无需发送 file_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; +} + +// v3: file_chunk 以二进制帧传输,不是 JSON 消息 +// 帧格式: Magic(2B) + TotalLen(4B) + Type(1B, 0x01) + TransferIdLen(2B) + TransferId(nB) + ChunkIndex(4B) + Data +export interface LanTransferFileChunkBinaryFrame { + type: 0x01; // 二进制帧类型标识 (file_chunk) + transferId: string; + chunkIndex: number; + data: Buffer; // 原始二进制数据 +} + +export interface LanTransferFileEndMessage { + type: "file_end"; + transferId: string; +} + +export interface LanTransferFileCancelMessage { + type: "file_cancel"; + transferId: string; + reason?: string; +} + +// 文件传输响应消息 (Server -> Client) +export interface LanTransferFileStartAckMessage { + type: "file_start_ack"; + transferId: string; + accepted: boolean; + message?: string; +} + +// v3: 移除了 LanTransferFileChunkAckMessage(流式传输无需逐块确认) + +export interface LanTransferFileCompleteMessage { + type: "file_complete"; + transferId: string; + success: boolean; + filePath?: string; + error?: string; + // v3 新增字段 + errorCode?: + | "CHECKSUM_MISMATCH" + | "INCOMPLETE_TRANSFER" + | "DISK_ERROR" + | "CANCELLED"; + receivedChunks?: number; + receivedBytes?: number; +} + +// 常量 +export const LAN_TRANSFER_PROTOCOL_VERSION = "3"; +export const LAN_TRANSFER_TCP_PORT = 53317; +export const LAN_TRANSFER_CHUNK_SIZE = 512 * 1024; +export const LAN_TRANSFER_MAX_FILE_SIZE = 500 * 1024 * 1024; +// v3: 移除了 CHUNK_TIMEOUT_MS(流式传输无需逐块等待超时) +``` + +--- + +## 附录 B:版本历史 + +| 版本 | 日期 | 变更 | +| ---- | ------- | ------------------------------------------------------------------ | +| 1.0 | 2025-12 | 初始版本,与移动端实现对齐 | +| 2.0 | 2025-12 | 引入二进制帧格式传输数据块,仍使用逐块 ACK 确认 | +| 3.0 | 2025-12 | 流式传输模式,移除 `file_chunk_ack`,增强 `file_complete` 错误诊断 | diff --git a/package.json b/package.json index 7ad1385fc..c8d32c53f 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,7 @@ "@napi-rs/system-ocr": "patch:@napi-rs/system-ocr@npm%3A1.0.2#~/.yarn/patches/@napi-rs-system-ocr-npm-1.0.2-59e7a78e8b.patch", "@paymoapp/electron-shutdown-handler": "^1.1.2", "@strongtz/win32-arm64-msvc": "^0.4.7", + "bonjour-service": "^1.3.0", "emoji-picker-element-data": "^1", "express": "^5.1.0", "font-list": "^2.0.0", diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index 167721a7f..50ccc12e0 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -229,6 +229,8 @@ export enum IpcChannel { Backup_ListS3Files = 'backup:listS3Files', Backup_DeleteS3File = 'backup:deleteS3File', Backup_CheckS3Connection = 'backup:checkS3Connection', + Backup_CreateLanTransferBackup = 'backup:createLanTransferBackup', + Backup_DeleteTempBackup = 'backup:deleteTempBackup', // zip Zip_Compress = 'zip:compress', @@ -377,5 +379,16 @@ export enum IpcChannel { WebSocket_Stop = 'webSocket:stop', WebSocket_Status = 'webSocket:status', WebSocket_SendFile = 'webSocket:send-file', - WebSocket_GetAllCandidates = 'webSocket:get-all-candidates' + WebSocket_GetAllCandidates = 'webSocket:get-all-candidates', + + // Local Transfer + LocalTransfer_ListServices = 'local-transfer:list', + LocalTransfer_StartScan = 'local-transfer:start-scan', + LocalTransfer_StopScan = 'local-transfer:stop-scan', + LocalTransfer_ServicesUpdated = 'local-transfer:services-updated', + LocalTransfer_Connect = 'local-transfer:connect', + LocalTransfer_Disconnect = 'local-transfer:disconnect', + LocalTransfer_ClientEvent = 'local-transfer:client-event', + LocalTransfer_SendFile = 'local-transfer:send-file', + LocalTransfer_CancelTransfer = 'local-transfer:cancel-transfer' } diff --git a/packages/shared/config/types.ts b/packages/shared/config/types.ts index 8fba6399f..b5f05e3d0 100644 --- a/packages/shared/config/types.ts +++ b/packages/shared/config/types.ts @@ -44,3 +44,196 @@ export interface WebSocketCandidatesResponse { interface: string priority: number } + +export type LocalTransferPeer = { + id: string + name: string + host?: string + fqdn?: string + port?: number + type?: string + protocol?: 'tcp' | 'udp' + addresses: string[] + txt?: Record + updatedAt: number +} + +export type LocalTransferState = { + services: LocalTransferPeer[] + isScanning: boolean + lastScanStartedAt?: number + lastUpdatedAt: number + lastError?: string +} + +export type LanHandshakeRequestMessage = { + type: 'handshake' + deviceName: string + version: string + platform?: string + appVersion?: string +} + +export type LanHandshakeAckMessage = { + type: 'handshake_ack' + accepted: boolean + message?: string +} + +export type LocalTransferConnectPayload = { + peerId: string + metadata?: Record + timeoutMs?: number +} + +export type LanClientEvent = + | { + type: 'ping_sent' + payload: string + timestamp: number + peerId?: string + peerName?: string + } + | { + type: 'pong' + payload?: string + received?: boolean + timestamp: number + peerId?: string + peerName?: string + } + | { + type: 'socket_closed' + reason?: string + timestamp: number + peerId?: string + peerName?: string + } + | { + type: 'error' + message: string + timestamp: number + peerId?: string + peerName?: string + } + | { + type: 'file_transfer_progress' + transferId: string + fileName: string + bytesSent: number + totalBytes: number + chunkIndex: number + totalChunks: number + progress: number // 0-100 + speed: number // bytes/sec + timestamp: number + peerId?: string + peerName?: string + } + | { + type: 'file_transfer_complete' + transferId: string + fileName: string + success: boolean + filePath?: string + error?: string + timestamp: number + peerId?: string + peerName?: string + } + +// ============================================================================= +// LAN File Transfer Protocol Types +// ============================================================================= + +// Constants for file transfer +export const LAN_TRANSFER_TCP_PORT = 53317 +export const LAN_TRANSFER_CHUNK_SIZE = 512 * 1024 // 512KB +export const LAN_TRANSFER_MAX_FILE_SIZE = 500 * 1024 * 1024 // 500MB +export const LAN_TRANSFER_COMPLETE_TIMEOUT_MS = 60_000 // 60s - wait for file_complete after file_end +export const LAN_TRANSFER_GLOBAL_TIMEOUT_MS = 10 * 60 * 1000 // 10 minutes - global transfer timeout + +// Binary protocol constants (v3) +export const LAN_TRANSFER_PROTOCOL_VERSION = '3' +export const LAN_BINARY_FRAME_MAGIC = 0x4353 // "CS" as uint16 +export const LAN_BINARY_TYPE_FILE_CHUNK = 0x01 + +// Messages from Electron (Client/Sender) to Mobile (Server/Receiver) + +/** Request to start file transfer */ +export type LanFileStartMessage = { + type: 'file_start' + transferId: string + fileName: string + fileSize: number + mimeType: string // 'application/zip' + checksum: string // SHA-256 of entire file + totalChunks: number + chunkSize: number +} + +/** + * File chunk data (JSON format) + * @deprecated Use binary frame format in protocol v2. This type is kept for reference only. + */ +export type LanFileChunkMessage = { + type: 'file_chunk' + transferId: string + chunkIndex: number + data: string // Base64 encoded + chunkChecksum: string // SHA-256 of this chunk +} + +/** Notification that all chunks have been sent */ +export type LanFileEndMessage = { + type: 'file_end' + transferId: string +} + +/** Request to cancel file transfer */ +export type LanFileCancelMessage = { + type: 'file_cancel' + transferId: string + reason?: string +} + +// Messages from Mobile (Server/Receiver) to Electron (Client/Sender) + +/** Acknowledgment of file transfer request */ +export type LanFileStartAckMessage = { + type: 'file_start_ack' + transferId: string + accepted: boolean + message?: string // Rejection reason +} + +/** + * Acknowledgment of file chunk received + * @deprecated Protocol v3 uses streaming mode without per-chunk acknowledgment. + * This type is kept for backward compatibility reference only. + */ +export type LanFileChunkAckMessage = { + type: 'file_chunk_ack' + transferId: string + chunkIndex: number + received: boolean + message?: string +} + +/** Final result of file transfer */ +export type LanFileCompleteMessage = { + type: 'file_complete' + transferId: string + success: boolean + filePath?: string // Path where file was saved on mobile + error?: string + // v3 enhanced error diagnostics + errorCode?: 'CHECKSUM_MISMATCH' | 'INCOMPLETE_TRANSFER' | 'DISK_ERROR' | 'CANCELLED' + receivedChunks?: number + receivedBytes?: number +} + +/** Payload for sending a file via IPC */ +export type LanFileSendPayload = { + filePath: string +} diff --git a/src/main/index.ts b/src/main/index.ts index 56750e6b6..657c31dfc 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -19,7 +19,9 @@ import { agentService } from './services/agents' import { apiServerService } from './services/ApiServerService' import { appMenuService } from './services/AppMenuService' import { configManager } from './services/ConfigManager' +import { lanTransferClientService } from './services/lanTransfer' import mcpService from './services/MCPService' +import { localTransferService } from './services/LocalTransferService' import { nodeTraceService } from './services/NodeTraceService' import powerMonitorService from './services/PowerMonitorService' import { @@ -156,6 +158,7 @@ if (!app.requestSingleInstanceLock()) { registerShortcuts(mainWindow) registerIpc(mainWindow, app) + localTransferService.startDiscovery({ resetList: true }) replaceDevtoolsFont(mainWindow) @@ -237,6 +240,9 @@ if (!app.requestSingleInstanceLock()) { if (selectionService) { selectionService.quit() } + + lanTransferClientService.dispose() + localTransferService.dispose() }) app.on('will-quit', async () => { diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 444ca5fb8..0bcfeb0af 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -11,6 +11,7 @@ import { handleZoomFactor } from '@main/utils/zoom' import type { SpanEntity, TokenUsage } from '@mcp-trace/trace-core' import type { UpgradeChannel } from '@shared/config/constant' import { MIN_WINDOW_HEIGHT, MIN_WINDOW_WIDTH } from '@shared/config/constant' +import type { LocalTransferConnectPayload } from '@shared/config/types' import { IpcChannel } from '@shared/IpcChannel' import type { PluginError } from '@types' import type { @@ -42,6 +43,8 @@ import { ExportService } from './services/ExportService' import { fileStorage as fileManager } from './services/FileStorage' import FileService from './services/FileSystemService' import KnowledgeService from './services/KnowledgeService' +import { lanTransferClientService } from './services/lanTransfer' +import { localTransferService } from './services/LocalTransferService' import mcpService from './services/MCPService' import MemoryService from './services/memory/MemoryService' import { openTraceWindow, setTraceWindowTitle } from './services/NodeTraceService' @@ -536,6 +539,8 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { ipcMain.handle(IpcChannel.Backup_ListS3Files, backupManager.listS3Files.bind(backupManager)) ipcMain.handle(IpcChannel.Backup_DeleteS3File, backupManager.deleteS3File.bind(backupManager)) ipcMain.handle(IpcChannel.Backup_CheckS3Connection, backupManager.checkS3Connection.bind(backupManager)) + ipcMain.handle(IpcChannel.Backup_CreateLanTransferBackup, backupManager.createLanTransferBackup.bind(backupManager)) + ipcMain.handle(IpcChannel.Backup_DeleteTempBackup, backupManager.deleteTempBackup.bind(backupManager)) // file ipcMain.handle(IpcChannel.File_Open, fileManager.open.bind(fileManager)) @@ -1062,6 +1067,18 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { ipcMain.handle(IpcChannel.WebSocket_SendFile, WebSocketService.sendFile) ipcMain.handle(IpcChannel.WebSocket_GetAllCandidates, WebSocketService.getAllCandidates) + ipcMain.handle(IpcChannel.LocalTransfer_ListServices, () => localTransferService.getState()) + ipcMain.handle(IpcChannel.LocalTransfer_StartScan, () => localTransferService.startDiscovery({ resetList: true })) + ipcMain.handle(IpcChannel.LocalTransfer_StopScan, () => localTransferService.stopDiscovery()) + ipcMain.handle(IpcChannel.LocalTransfer_Connect, (_, payload: LocalTransferConnectPayload) => + lanTransferClientService.connectAndHandshake(payload) + ) + ipcMain.handle(IpcChannel.LocalTransfer_Disconnect, () => lanTransferClientService.disconnect()) + ipcMain.handle(IpcChannel.LocalTransfer_SendFile, (_, payload: { filePath: string }) => + lanTransferClientService.sendFile(payload.filePath) + ) + ipcMain.handle(IpcChannel.LocalTransfer_CancelTransfer, () => lanTransferClientService.cancelTransfer()) + ipcMain.handle(IpcChannel.APP_CrashRenderProcess, () => { mainWindow.webContents.forcefullyCrashRenderer() }) diff --git a/src/main/services/BackupManager.ts b/src/main/services/BackupManager.ts index f331254fd..5af4c9f67 100644 --- a/src/main/services/BackupManager.ts +++ b/src/main/services/BackupManager.ts @@ -767,6 +767,55 @@ class BackupManager { const s3Client = this.getS3Storage(s3Config) return await s3Client.checkConnection() } + + /** + * Create a temporary backup for LAN transfer + * Creates a lightweight backup (skipBackupFile=true) in the temp directory + * Returns the path to the created ZIP file + */ + async createLanTransferBackup(_: Electron.IpcMainInvokeEvent, data: string): Promise { + const timestamp = new Date() + .toISOString() + .replace(/[-:T.Z]/g, '') + .slice(0, 12) + const fileName = `cherry-studio.${timestamp}.zip` + const tempPath = path.join(app.getPath('temp'), 'cherry-studio', 'lan-transfer') + + // Ensure temp directory exists + await fs.ensureDir(tempPath) + + // Create backup with skipBackupFile=true (no Data folder) + const backupedFilePath = await this.backup(_, fileName, data, tempPath, true) + + logger.info(`[BackupManager] Created LAN transfer backup at: ${backupedFilePath}`) + return backupedFilePath + } + + /** + * Delete a temporary backup file after LAN transfer completes + */ + async deleteTempBackup(_: Electron.IpcMainInvokeEvent, filePath: string): Promise { + try { + // Security check: only allow deletion within temp directory + const tempBase = path.join(app.getPath('temp'), 'cherry-studio', 'lan-transfer') + const resolvedPath = path.resolve(filePath) + + if (!resolvedPath.startsWith(tempBase)) { + logger.warn(`[BackupManager] Attempted to delete file outside temp directory: ${filePath}`) + return false + } + + if (await fs.pathExists(resolvedPath)) { + await fs.remove(resolvedPath) + logger.info(`[BackupManager] Deleted temp backup: ${resolvedPath}`) + return true + } + return false + } catch (error) { + logger.error('[BackupManager] Failed to delete temp backup:', error as Error) + return false + } + } } export default BackupManager diff --git a/src/main/services/LocalTransferService.ts b/src/main/services/LocalTransferService.ts new file mode 100644 index 000000000..bc2743757 --- /dev/null +++ b/src/main/services/LocalTransferService.ts @@ -0,0 +1,207 @@ +import { loggerService } from '@logger' +import type { LocalTransferPeer, LocalTransferState } from '@shared/config/types' +import { IpcChannel } from '@shared/IpcChannel' +import type { Browser, Service } from 'bonjour-service' +import Bonjour from 'bonjour-service' + +import { windowService } from './WindowService' + +const SERVICE_TYPE = 'cherrystudio' +const SERVICE_PROTOCOL = 'tcp' as const + +const logger = loggerService.withContext('LocalTransferService') + +type StartDiscoveryOptions = { + resetList?: boolean +} + +class LocalTransferService { + private static instance: LocalTransferService + private bonjour: Bonjour | null = null + private browser: Browser | null = null + private services = new Map() + private isScanning = false + private lastScanStartedAt?: number + private lastUpdatedAt = Date.now() + private lastError?: string + + private constructor() {} + + public static getInstance(): LocalTransferService { + if (!LocalTransferService.instance) { + LocalTransferService.instance = new LocalTransferService() + } + return LocalTransferService.instance + } + + public startDiscovery(options?: StartDiscoveryOptions): LocalTransferState { + if (options?.resetList) { + this.services.clear() + } + + this.isScanning = true + this.lastScanStartedAt = Date.now() + this.lastUpdatedAt = Date.now() + this.lastError = undefined + this.restartBrowser() + this.broadcastState() + return this.getState() + } + + public stopDiscovery(): LocalTransferState { + if (this.browser) { + try { + this.browser.stop() + } catch (error) { + logger.warn('Failed to stop local transfer browser', error as Error) + } + } + this.isScanning = false + this.lastUpdatedAt = Date.now() + this.broadcastState() + return this.getState() + } + + public getState(): LocalTransferState { + const services = Array.from(this.services.values()).sort((a, b) => a.name.localeCompare(b.name)) + return { + services, + isScanning: this.isScanning, + lastScanStartedAt: this.lastScanStartedAt, + lastUpdatedAt: this.lastUpdatedAt, + lastError: this.lastError + } + } + + public getPeerById(id: string): LocalTransferPeer | undefined { + return this.services.get(id) + } + + public dispose(): void { + this.stopDiscovery() + this.services.clear() + this.browser?.removeAllListeners() + this.browser = null + if (this.bonjour) { + try { + this.bonjour.destroy() + } catch (error) { + logger.warn('Failed to destroy Bonjour instance', error as Error) + } + this.bonjour = null + } + } + + private getBonjour(): Bonjour { + if (!this.bonjour) { + this.bonjour = new Bonjour() + } + return this.bonjour + } + + private restartBrowser(): void { + // Clean up existing browser + if (this.browser) { + this.browser.removeAllListeners() + try { + this.browser.stop() + } catch (error) { + logger.warn('Error while stopping Bonjour browser', error as Error) + } + this.browser = null + } + + // Destroy and recreate Bonjour instance to prevent socket leaks + if (this.bonjour) { + try { + this.bonjour.destroy() + } catch (error) { + logger.warn('Error while destroying Bonjour instance', error as Error) + } + this.bonjour = null + } + + const browser = this.getBonjour().find({ type: SERVICE_TYPE, protocol: SERVICE_PROTOCOL }) + this.browser = browser + this.bindBrowserEvents(browser) + + try { + browser.start() + logger.info('Local transfer discovery started') + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)) + this.lastError = err.message + logger.error('Failed to start local transfer discovery', err) + } + } + + private bindBrowserEvents(browser: Browser) { + browser.on('up', (service) => { + const peer = this.normalizeService(service) + logger.info(`LAN peer detected: ${peer.name} (${peer.addresses.join(', ')})`) + this.services.set(peer.id, peer) + this.lastUpdatedAt = Date.now() + this.broadcastState() + }) + + browser.on('down', (service) => { + const key = this.buildServiceKey(service.fqdn || service.name, service.host, service.port) + if (this.services.delete(key)) { + logger.info(`LAN peer removed: ${service.name}`) + this.lastUpdatedAt = Date.now() + this.broadcastState() + } + }) + + browser.on('error', (error) => { + const err = error instanceof Error ? error : new Error(String(error)) + logger.error('Local transfer discovery error', err) + this.lastError = err.message + this.broadcastState() + }) + } + + private normalizeService(service: Service): LocalTransferPeer { + const addressCandidates = [...(service.addresses || []), service.referer?.address].filter( + (value): value is string => typeof value === 'string' && value.length > 0 + ) + const addresses = Array.from(new Set(addressCandidates)) + const txtEntries = Object.entries(service.txt || {}) + const txt = + txtEntries.length > 0 + ? Object.fromEntries( + txtEntries.map(([key, value]) => [key, value === undefined || value === null ? '' : String(value)]) + ) + : undefined + + const peer: LocalTransferPeer = { + id: this.buildServiceKey(service.fqdn || service.name, service.host, service.port), + name: service.name, + host: service.host, + fqdn: service.fqdn, + port: service.port, + type: service.type, + protocol: service.protocol, + addresses, + txt, + updatedAt: Date.now() + } + + return peer + } + + private buildServiceKey(name?: string, host?: string, port?: number): string { + const raw = [name, host, port?.toString()].filter(Boolean).join('-') + return raw || `service-${Date.now()}` + } + + private broadcastState() { + const mainWindow = windowService.getMainWindow() + if (!mainWindow || mainWindow.isDestroyed()) { + return + } + mainWindow.webContents.send(IpcChannel.LocalTransfer_ServicesUpdated, this.getState()) + } +} + +export const localTransferService = LocalTransferService.getInstance() diff --git a/src/main/services/lanTransfer/LanTransferClientService.ts b/src/main/services/lanTransfer/LanTransferClientService.ts new file mode 100644 index 000000000..fb8019248 --- /dev/null +++ b/src/main/services/lanTransfer/LanTransferClientService.ts @@ -0,0 +1,486 @@ +import * as crypto from 'node:crypto' +import { createConnection, type Socket } from 'node:net' + +import { loggerService } from '@logger' +import type { + LanClientEvent, + LanFileCompleteMessage, + LanHandshakeAckMessage, + LocalTransferConnectPayload, + LocalTransferPeer +} from '@shared/config/types' +import { LAN_TRANSFER_GLOBAL_TIMEOUT_MS } from '@shared/config/types' +import { IpcChannel } from '@shared/IpcChannel' + +import { localTransferService } from '../LocalTransferService' +import { windowService } from '../WindowService' +import { + abortTransfer, + buildHandshakeMessage, + calculateFileChecksum, + cleanupTransfer, + createDataHandler, + createTransferState, + formatFileSize, + HANDSHAKE_PROTOCOL_VERSION, + pickHost, + sendFileEnd, + sendFileStart, + sendTestPing, + streamFileChunks, + validateFile, + waitForFileComplete, + waitForFileStartAck +} from './handlers' +import { ResponseManager } from './responseManager' +import type { ActiveFileTransfer, ConnectionContext, FileTransferContext } from './types' + +const DEFAULT_HANDSHAKE_TIMEOUT_MS = 10_000 + +const logger = loggerService.withContext('LanTransferClientService') + +/** + * LAN Transfer Client Service + * + * Handles outgoing file transfers to LAN peers via TCP. + * Protocol v3 with streaming mode (no per-chunk acknowledgment). + */ +class LanTransferClientService { + private socket: Socket | null = null + private currentPeer?: LocalTransferPeer + private dataHandler?: ReturnType + private responseManager = new ResponseManager() + private isConnecting = false + private activeTransfer?: ActiveFileTransfer + private lastConnectOptions?: LocalTransferConnectPayload + + constructor() { + this.responseManager.setTimeoutCallback(() => void this.disconnect()) + } + + /** + * Connect to a LAN peer and perform handshake. + */ + public async connectAndHandshake(options: LocalTransferConnectPayload): Promise { + if (this.isConnecting) { + throw new Error('LAN transfer client is busy') + } + + const peer = localTransferService.getPeerById(options.peerId) + if (!peer) { + throw new Error('Selected LAN peer is no longer available') + } + if (!peer.port) { + throw new Error('Selected peer does not expose a TCP port') + } + + const host = pickHost(peer) + if (!host) { + throw new Error('Unable to resolve a reachable host for the peer') + } + + await this.disconnect() + this.isConnecting = true + + return new Promise((resolve, reject) => { + const socket = createConnection({ host, port: peer.port as number }, () => { + logger.info(`Connected to LAN peer ${peer.name} (${host}:${peer.port})`) + socket.setKeepAlive(true, 30_000) + this.socket = socket + this.currentPeer = peer + this.attachSocketListeners(socket) + + this.responseManager.waitForResponse( + 'handshake_ack', + options.timeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS, + (payload) => { + const ack = payload as LanHandshakeAckMessage + if (!ack.accepted) { + const message = ack.message || 'Handshake rejected by remote device' + logger.warn(`Handshake rejected by ${peer.name}: ${message}`) + this.broadcastClientEvent({ + type: 'error', + message, + timestamp: Date.now() + }) + reject(new Error(message)) + void this.disconnect() + return + } + logger.info(`Handshake accepted by ${peer.name}`) + socket.setTimeout(0) + this.isConnecting = false + this.lastConnectOptions = options + sendTestPing(this.createConnectionContext()) + resolve(ack) + }, + (error) => { + this.isConnecting = false + reject(error) + } + ) + + const handshakeMessage = buildHandshakeMessage() + this.sendControlMessage(handshakeMessage) + }) + + socket.setTimeout(options.timeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS, () => { + const error = new Error('Handshake timed out') + logger.error('LAN transfer socket timeout', error) + this.broadcastClientEvent({ + type: 'error', + message: error.message, + timestamp: Date.now() + }) + reject(error) + socket.destroy(error) + void this.disconnect() + }) + + socket.once('error', (error) => { + logger.error('LAN transfer socket error', error as Error) + const message = error instanceof Error ? error.message : String(error) + this.broadcastClientEvent({ + type: 'error', + message, + timestamp: Date.now() + }) + this.isConnecting = false + reject(error instanceof Error ? error : new Error(message)) + void this.disconnect() + }) + + socket.once('close', () => { + logger.info('LAN transfer socket closed') + if (this.socket === socket) { + this.socket = null + this.dataHandler?.resetBuffer() + this.responseManager.rejectAll(new Error('LAN transfer socket closed')) + this.currentPeer = undefined + abortTransfer(this.activeTransfer, new Error('LAN transfer socket closed')) + } + this.isConnecting = false + this.broadcastClientEvent({ + type: 'socket_closed', + reason: 'connection_closed', + timestamp: Date.now() + }) + }) + }) + } + + /** + * Disconnect from the current peer. + */ + public async disconnect(): Promise { + const socket = this.socket + if (!socket) { + return + } + + this.socket = null + this.dataHandler?.resetBuffer() + this.currentPeer = undefined + this.responseManager.rejectAll(new Error('LAN transfer socket disconnected')) + abortTransfer(this.activeTransfer, new Error('LAN transfer socket disconnected')) + + const DISCONNECT_TIMEOUT_MS = 3000 + await new Promise((resolve) => { + const timeout = setTimeout(() => { + logger.warn('Disconnect timeout, forcing cleanup') + socket.removeAllListeners() + resolve() + }, DISCONNECT_TIMEOUT_MS) + + socket.once('close', () => { + clearTimeout(timeout) + resolve() + }) + + socket.destroy() + }) + } + + /** + * Dispose the service and clean up all resources. + */ + public dispose(): void { + this.responseManager.rejectAll(new Error('LAN transfer client disposed')) + cleanupTransfer(this.activeTransfer) + this.activeTransfer = undefined + if (this.socket) { + this.socket.destroy() + this.socket = null + } + this.dataHandler?.resetBuffer() + this.isConnecting = false + } + + /** + * Send a ZIP file to the connected peer. + */ + public async sendFile(filePath: string): Promise { + await this.ensureConnection() + + if (this.activeTransfer) { + throw new Error('A file transfer is already in progress') + } + + // Validate file + const { stats, fileName } = await validateFile(filePath) + + // Calculate checksum + logger.info('Calculating file checksum...') + const checksum = await calculateFileChecksum(filePath) + logger.info(`File checksum: ${checksum.substring(0, 16)}...`) + + // Connection can drop while validating/checking file; ensure it is still ready before starting transfer. + await this.ensureConnection() + + // Initialize transfer state + const transferId = crypto.randomUUID() + this.activeTransfer = createTransferState(transferId, fileName, stats.size, checksum) + + logger.info( + `Starting file transfer: ${fileName} (${formatFileSize(stats.size)}, ${this.activeTransfer.totalChunks} chunks)` + ) + + // Global timeout + const globalTimeoutError = new Error('Transfer timed out (global timeout exceeded)') + const globalTimeoutHandle = setTimeout(() => { + logger.warn('Global transfer timeout exceeded, aborting transfer', { transferId, fileName }) + abortTransfer(this.activeTransfer, globalTimeoutError) + }, LAN_TRANSFER_GLOBAL_TIMEOUT_MS) + + try { + const result = await this.performFileTransfer(filePath, transferId, fileName) + return result + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + logger.error(`File transfer failed: ${message}`) + + this.broadcastClientEvent({ + type: 'file_transfer_complete', + transferId, + fileName, + success: false, + error: message, + timestamp: Date.now() + }) + + throw error + } finally { + clearTimeout(globalTimeoutHandle) + cleanupTransfer(this.activeTransfer) + this.activeTransfer = undefined + } + } + + /** + * Cancel the current file transfer. + */ + public cancelTransfer(): void { + if (!this.activeTransfer) { + logger.warn('No active transfer to cancel') + return + } + + const { transferId, fileName } = this.activeTransfer + logger.info(`Cancelling file transfer: ${fileName}`) + + this.activeTransfer.isCancelled = true + + try { + this.sendControlMessage({ + type: 'file_cancel', + transferId, + reason: 'Cancelled by user' + }) + } catch { + // Ignore errors when sending cancel message + } + + abortTransfer(this.activeTransfer, new Error('Transfer cancelled by user')) + } + + // ============================================================================= + // Private Methods + // ============================================================================= + + private async ensureConnection(): Promise { + // Check socket is valid and writable (not just undestroyed) + if (this.socket && !this.socket.destroyed && this.socket.writable && this.currentPeer) { + return + } + + if (!this.lastConnectOptions) { + throw new Error('No active connection. Please connect to a peer first.') + } + + logger.info('Connection lost, attempting to reconnect...') + await this.connectAndHandshake(this.lastConnectOptions) + } + + private async performFileTransfer( + filePath: string, + transferId: string, + fileName: string + ): Promise { + const transfer = this.activeTransfer! + const ctx = this.createFileTransferContext() + + // Step 1: Send file_start + sendFileStart(ctx, transfer) + + // Step 2: Wait for file_start_ack + const startAck = await waitForFileStartAck(ctx, transferId, transfer.abortController.signal) + if (!startAck.accepted) { + throw new Error(startAck.message || 'Transfer rejected by receiver') + } + logger.info('Received file_start_ack: accepted') + + // Step 3: Stream file chunks + await streamFileChunks(this.socket!, filePath, transfer, transfer.abortController.signal, (bytesSent, chunkIndex) => + this.onTransferProgress(transfer, bytesSent, chunkIndex) + ) + + // Step 4: Send file_end + sendFileEnd(ctx, transferId) + + // Step 5: Wait for file_complete + const result = await waitForFileComplete(ctx, transferId, transfer.abortController.signal) + logger.info(`File transfer ${result.success ? 'completed' : 'failed'}`) + + // Broadcast completion + this.broadcastClientEvent({ + type: 'file_transfer_complete', + transferId, + fileName, + success: result.success, + filePath: result.filePath, + error: result.error, + timestamp: Date.now() + }) + + return result + } + + private onTransferProgress(transfer: ActiveFileTransfer, bytesSent: number, chunkIndex: number): void { + const progress = (bytesSent / transfer.fileSize) * 100 + const elapsed = (Date.now() - transfer.startedAt) / 1000 + const speed = elapsed > 0 ? bytesSent / elapsed : 0 + + this.broadcastClientEvent({ + type: 'file_transfer_progress', + transferId: transfer.transferId, + fileName: transfer.fileName, + bytesSent, + totalBytes: transfer.fileSize, + chunkIndex, + totalChunks: transfer.totalChunks, + progress: Math.round(progress * 100) / 100, + speed, + timestamp: Date.now() + }) + } + + private attachSocketListeners(socket: Socket): void { + this.dataHandler = createDataHandler((line) => this.handleControlLine(line)) + socket.on('data', (chunk: Buffer) => this.dataHandler?.handleData(chunk)) + } + + private handleControlLine(line: string): void { + let payload: Record + try { + payload = JSON.parse(line) + } catch { + logger.warn('Received invalid JSON control message', { line }) + return + } + + const type = payload?.type as string | undefined + if (!type) { + logger.warn('Received control message without type', payload) + return + } + + // Try to resolve a pending response + const transferId = payload?.transferId as string | undefined + const chunkIndex = payload?.chunkIndex as number | undefined + if (this.responseManager.tryResolve(type, payload, transferId, chunkIndex)) { + return + } + + logger.info('Received control message', payload) + + if (type === 'pong') { + this.broadcastClientEvent({ + type: 'pong', + payload: payload?.payload as string | undefined, + received: payload?.received as boolean | undefined, + timestamp: Date.now() + }) + return + } + + // Ignore late-arriving file transfer messages + const fileTransferMessageTypes = ['file_start_ack', 'file_complete'] + if (fileTransferMessageTypes.includes(type)) { + logger.debug('Ignoring late file transfer message', { type, payload }) + return + } + + this.broadcastClientEvent({ + type: 'error', + message: `Unexpected control message type: ${type}`, + timestamp: Date.now() + }) + } + + private sendControlMessage(message: Record): void { + if (!this.socket || this.socket.destroyed || !this.socket.writable) { + throw new Error('Socket is not connected') + } + const payload = JSON.stringify(message) + this.socket.write(`${payload}\n`) + } + + private createConnectionContext(): ConnectionContext { + return { + socket: this.socket, + currentPeer: this.currentPeer, + sendControlMessage: (msg) => this.sendControlMessage(msg), + broadcastClientEvent: (event) => this.broadcastClientEvent(event) + } + } + + private createFileTransferContext(): FileTransferContext { + return { + ...this.createConnectionContext(), + activeTransfer: this.activeTransfer, + setActiveTransfer: (transfer) => { + this.activeTransfer = transfer + }, + waitForResponse: (type, timeoutMs, resolve, reject, transferId, chunkIndex, abortSignal) => { + this.responseManager.waitForResponse(type, timeoutMs, resolve, reject, transferId, chunkIndex, abortSignal) + } + } + } + + private broadcastClientEvent(event: LanClientEvent): void { + const mainWindow = windowService.getMainWindow() + if (!mainWindow || mainWindow.isDestroyed()) { + return + } + mainWindow.webContents.send(IpcChannel.LocalTransfer_ClientEvent, { + ...event, + peerId: event.peerId ?? this.currentPeer?.id, + peerName: event.peerName ?? this.currentPeer?.name + }) + } +} + +export const lanTransferClientService = new LanTransferClientService() + +// Re-export for backward compatibility +export { HANDSHAKE_PROTOCOL_VERSION } diff --git a/src/main/services/lanTransfer/__tests__/binaryProtocol.test.ts b/src/main/services/lanTransfer/__tests__/binaryProtocol.test.ts new file mode 100644 index 000000000..cf9d432df --- /dev/null +++ b/src/main/services/lanTransfer/__tests__/binaryProtocol.test.ts @@ -0,0 +1,89 @@ +import { EventEmitter } from 'node:events' +import type { Socket } from 'node:net' + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { BINARY_TYPE_FILE_CHUNK, sendBinaryChunk } from '../binaryProtocol' + +describe('binaryProtocol', () => { + describe('sendBinaryChunk', () => { + let mockSocket: Socket + let writtenBuffers: Buffer[] + + beforeEach(() => { + writtenBuffers = [] + mockSocket = Object.assign(new EventEmitter(), { + write: vi.fn((buffer: Buffer) => { + writtenBuffers.push(Buffer.from(buffer)) + return true + }), + cork: vi.fn(), + uncork: vi.fn() + }) as unknown as Socket + }) + + it('should send binary chunk with correct frame format', () => { + const transferId = 'test-uuid-1234' + const chunkIndex = 5 + const data = Buffer.from('test data chunk') + + const result = sendBinaryChunk(mockSocket, transferId, chunkIndex, data) + + expect(result).toBe(true) + expect(mockSocket.cork).toHaveBeenCalled() + expect(mockSocket.uncork).toHaveBeenCalled() + expect(mockSocket.write).toHaveBeenCalledTimes(2) + + // Verify header structure + const header = writtenBuffers[0] + + // Magic bytes "CS" + expect(header[0]).toBe(0x43) + expect(header[1]).toBe(0x53) + + // Type byte + const typeOffset = 2 + 4 // magic + totalLen + expect(header[typeOffset]).toBe(BINARY_TYPE_FILE_CHUNK) + + // TransferId length + const tidLenOffset = typeOffset + 1 + const tidLen = header.readUInt16BE(tidLenOffset) + expect(tidLen).toBe(Buffer.from(transferId).length) + + // ChunkIndex + const chunkIdxOffset = tidLenOffset + 2 + tidLen + expect(header.readUInt32BE(chunkIdxOffset)).toBe(chunkIndex) + + // Data buffer + expect(writtenBuffers[1].toString()).toBe('test data chunk') + }) + + it('should return false when socket write returns false (backpressure)', () => { + ;(mockSocket.write as ReturnType).mockReturnValueOnce(false) + + const result = sendBinaryChunk(mockSocket, 'test-id', 0, Buffer.from('data')) + + expect(result).toBe(false) + }) + + it('should correctly calculate totalLen in frame header', () => { + const transferId = 'uuid-1234' + const data = Buffer.from('chunk data here') + + sendBinaryChunk(mockSocket, transferId, 0, data) + + const header = writtenBuffers[0] + const totalLen = header.readUInt32BE(2) // After magic bytes + + // totalLen = type(1) + tidLen(2) + tid(n) + idx(4) + data(m) + const expectedTotalLen = 1 + 2 + Buffer.from(transferId).length + 4 + data.length + expect(totalLen).toBe(expectedTotalLen) + }) + }) + + describe('BINARY_TYPE_FILE_CHUNK', () => { + it('should be 0x01', () => { + expect(BINARY_TYPE_FILE_CHUNK).toBe(0x01) + }) + }) +}) diff --git a/src/main/services/lanTransfer/__tests__/handlers/connection.test.ts b/src/main/services/lanTransfer/__tests__/handlers/connection.test.ts new file mode 100644 index 000000000..e889dc95a --- /dev/null +++ b/src/main/services/lanTransfer/__tests__/handlers/connection.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it, vi } from 'vitest' + +import { + buildHandshakeMessage, + createDataHandler, + getAbortError, + HANDSHAKE_PROTOCOL_VERSION, + pickHost +} from '../../handlers/connection' + +// Mock electron app +vi.mock('electron', () => ({ + app: { + getName: vi.fn(() => 'Cherry Studio'), + getVersion: vi.fn(() => '1.0.0') + } +})) + +describe('connection handlers', () => { + describe('buildHandshakeMessage', () => { + it('should build handshake message with correct structure', () => { + const message = buildHandshakeMessage() + + expect(message.type).toBe('handshake') + expect(message.deviceName).toBe('Cherry Studio') + expect(message.version).toBe(HANDSHAKE_PROTOCOL_VERSION) + expect(message.appVersion).toBe('1.0.0') + expect(typeof message.platform).toBe('string') + }) + + it('should use protocol version 3', () => { + expect(HANDSHAKE_PROTOCOL_VERSION).toBe('3') + }) + }) + + describe('pickHost', () => { + it('should prefer IPv4 addresses', () => { + const peer = { + id: '1', + name: 'Test', + addresses: ['fe80::1', '192.168.1.100', '::1'], + updatedAt: Date.now() + } + + expect(pickHost(peer)).toBe('192.168.1.100') + }) + + it('should fall back to first address if no IPv4', () => { + const peer = { + id: '1', + name: 'Test', + addresses: ['fe80::1', '::1'], + updatedAt: Date.now() + } + + expect(pickHost(peer)).toBe('fe80::1') + }) + + it('should fall back to host property if no addresses', () => { + const peer = { + id: '1', + name: 'Test', + host: 'example.local', + addresses: [], + updatedAt: Date.now() + } + + expect(pickHost(peer)).toBe('example.local') + }) + + it('should return undefined if no addresses or host', () => { + const peer = { + id: '1', + name: 'Test', + addresses: [], + updatedAt: Date.now() + } + + expect(pickHost(peer)).toBeUndefined() + }) + }) + + describe('createDataHandler', () => { + it('should parse complete lines from buffer', () => { + const lines: string[] = [] + const handler = createDataHandler((line) => lines.push(line)) + + handler.handleData(Buffer.from('{"type":"test"}\n')) + + expect(lines).toEqual(['{"type":"test"}']) + }) + + it('should handle partial lines across multiple chunks', () => { + const lines: string[] = [] + const handler = createDataHandler((line) => lines.push(line)) + + handler.handleData(Buffer.from('{"type":')) + handler.handleData(Buffer.from('"test"}\n')) + + expect(lines).toEqual(['{"type":"test"}']) + }) + + it('should handle multiple lines in single chunk', () => { + const lines: string[] = [] + const handler = createDataHandler((line) => lines.push(line)) + + handler.handleData(Buffer.from('{"a":1}\n{"b":2}\n')) + + expect(lines).toEqual(['{"a":1}', '{"b":2}']) + }) + + it('should reset buffer', () => { + const lines: string[] = [] + const handler = createDataHandler((line) => lines.push(line)) + + handler.handleData(Buffer.from('partial')) + handler.resetBuffer() + handler.handleData(Buffer.from('{"complete":true}\n')) + + expect(lines).toEqual(['{"complete":true}']) + }) + + it('should trim whitespace from lines', () => { + const lines: string[] = [] + const handler = createDataHandler((line) => lines.push(line)) + + handler.handleData(Buffer.from(' {"type":"test"} \n')) + + expect(lines).toEqual(['{"type":"test"}']) + }) + + it('should skip empty lines', () => { + const lines: string[] = [] + const handler = createDataHandler((line) => lines.push(line)) + + handler.handleData(Buffer.from('\n\n{"type":"test"}\n\n')) + + expect(lines).toEqual(['{"type":"test"}']) + }) + }) + + describe('getAbortError', () => { + it('should return Error reason directly', () => { + const originalError = new Error('Original') + const signal = { aborted: true, reason: originalError } as AbortSignal + + expect(getAbortError(signal, 'Fallback')).toBe(originalError) + }) + + it('should create Error from string reason', () => { + const signal = { aborted: true, reason: 'String reason' } as AbortSignal + + expect(getAbortError(signal, 'Fallback').message).toBe('String reason') + }) + + it('should use fallback for empty reason', () => { + const signal = { aborted: true, reason: '' } as AbortSignal + + expect(getAbortError(signal, 'Fallback').message).toBe('Fallback') + }) + }) +}) diff --git a/src/main/services/lanTransfer/__tests__/handlers/fileTransfer.test.ts b/src/main/services/lanTransfer/__tests__/handlers/fileTransfer.test.ts new file mode 100644 index 000000000..4119cd044 --- /dev/null +++ b/src/main/services/lanTransfer/__tests__/handlers/fileTransfer.test.ts @@ -0,0 +1,152 @@ +import type * as fs from 'node:fs' + +import { describe, expect, it, vi } from 'vitest' + +import { abortTransfer, cleanupTransfer, createTransferState, formatFileSize } from '../../handlers/fileTransfer' +import type { ActiveFileTransfer } from '../../types' + +// Note: validateFile and calculateFileChecksum tests are skipped because +// the test environment has globally mocked node:fs and node:os modules. +// These functions are tested through integration tests instead. + +describe('fileTransfer handlers', () => { + describe('createTransferState', () => { + it('should create transfer state with correct defaults', () => { + const state = createTransferState('uuid-123', 'test.zip', 1024000, 'abc123') + + expect(state.transferId).toBe('uuid-123') + expect(state.fileName).toBe('test.zip') + expect(state.fileSize).toBe(1024000) + expect(state.checksum).toBe('abc123') + expect(state.bytesSent).toBe(0) + expect(state.currentChunk).toBe(0) + expect(state.isCancelled).toBe(false) + expect(state.abortController).toBeInstanceOf(AbortController) + }) + + it('should calculate totalChunks based on chunk size', () => { + // 512KB chunk size + const state = createTransferState('id', 'test.zip', 1024 * 1024, 'checksum') // 1MB + + expect(state.totalChunks).toBe(2) // 1MB / 512KB = 2 + }) + }) + + describe('abortTransfer', () => { + it('should abort transfer and destroy stream', () => { + const mockStream = { + destroyed: false, + destroy: vi.fn() + } as unknown as fs.ReadStream + + const transfer: ActiveFileTransfer = { + transferId: 'test', + fileName: 'test.zip', + fileSize: 1000, + checksum: 'abc', + totalChunks: 1, + chunkSize: 512000, + bytesSent: 0, + currentChunk: 0, + startedAt: Date.now(), + stream: mockStream, + isCancelled: false, + abortController: new AbortController() + } + + const error = new Error('Test abort') + abortTransfer(transfer, error) + + expect(transfer.isCancelled).toBe(true) + expect(transfer.abortController.signal.aborted).toBe(true) + expect(mockStream.destroy).toHaveBeenCalledWith(error) + }) + + it('should handle undefined transfer', () => { + expect(() => abortTransfer(undefined, new Error('test'))).not.toThrow() + }) + + it('should not abort already aborted controller', () => { + const transfer: ActiveFileTransfer = { + transferId: 'test', + fileName: 'test.zip', + fileSize: 1000, + checksum: 'abc', + totalChunks: 1, + chunkSize: 512000, + bytesSent: 0, + currentChunk: 0, + startedAt: Date.now(), + isCancelled: false, + abortController: new AbortController() + } + + transfer.abortController.abort() + + // Should not throw when aborting again + expect(() => abortTransfer(transfer, new Error('test'))).not.toThrow() + }) + }) + + describe('cleanupTransfer', () => { + it('should cleanup transfer resources', () => { + const mockStream = { + destroyed: false, + destroy: vi.fn() + } as unknown as fs.ReadStream + + const transfer: ActiveFileTransfer = { + transferId: 'test', + fileName: 'test.zip', + fileSize: 1000, + checksum: 'abc', + totalChunks: 1, + chunkSize: 512000, + bytesSent: 0, + currentChunk: 0, + startedAt: Date.now(), + stream: mockStream, + isCancelled: false, + abortController: new AbortController() + } + + cleanupTransfer(transfer) + + expect(transfer.abortController.signal.aborted).toBe(true) + expect(mockStream.destroy).toHaveBeenCalled() + }) + + it('should handle undefined transfer', () => { + expect(() => cleanupTransfer(undefined)).not.toThrow() + }) + }) + + describe('formatFileSize', () => { + it('should format 0 bytes', () => { + expect(formatFileSize(0)).toBe('0 B') + }) + + it('should format bytes', () => { + expect(formatFileSize(500)).toBe('500 B') + }) + + it('should format kilobytes', () => { + expect(formatFileSize(1024)).toBe('1 KB') + expect(formatFileSize(2048)).toBe('2 KB') + }) + + it('should format megabytes', () => { + expect(formatFileSize(1024 * 1024)).toBe('1 MB') + expect(formatFileSize(5 * 1024 * 1024)).toBe('5 MB') + }) + + it('should format gigabytes', () => { + expect(formatFileSize(1024 * 1024 * 1024)).toBe('1 GB') + }) + + it('should format with decimal precision', () => { + expect(formatFileSize(1536)).toBe('1.5 KB') + expect(formatFileSize(1.5 * 1024 * 1024)).toBe('1.5 MB') + }) + }) +}) diff --git a/src/main/services/lanTransfer/__tests__/responseManager.test.ts b/src/main/services/lanTransfer/__tests__/responseManager.test.ts new file mode 100644 index 000000000..170ee2de8 --- /dev/null +++ b/src/main/services/lanTransfer/__tests__/responseManager.test.ts @@ -0,0 +1,177 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { ResponseManager } from '../responseManager' + +describe('ResponseManager', () => { + let manager: ResponseManager + + beforeEach(() => { + vi.useFakeTimers() + manager = new ResponseManager() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + describe('buildResponseKey', () => { + it('should build key with type only', () => { + expect(manager.buildResponseKey('handshake_ack')).toBe('handshake_ack') + }) + + it('should build key with type and transferId', () => { + expect(manager.buildResponseKey('file_start_ack', 'uuid-123')).toBe('file_start_ack:uuid-123') + }) + + it('should build key with type, transferId, and chunkIndex', () => { + expect(manager.buildResponseKey('file_chunk_ack', 'uuid-123', 5)).toBe('file_chunk_ack:uuid-123:5') + }) + }) + + describe('waitForResponse', () => { + it('should resolve when tryResolve is called with matching key', async () => { + const resolvePromise = new Promise((resolve, reject) => { + manager.waitForResponse('handshake_ack', 5000, resolve, reject) + }) + + const payload = { type: 'handshake_ack', accepted: true } + const resolved = manager.tryResolve('handshake_ack', payload) + + expect(resolved).toBe(true) + await expect(resolvePromise).resolves.toEqual(payload) + }) + + it('should reject on timeout', async () => { + const resolvePromise = new Promise((resolve, reject) => { + manager.waitForResponse('handshake_ack', 1000, resolve, reject) + }) + + vi.advanceTimersByTime(1001) + + await expect(resolvePromise).rejects.toThrow('Timeout waiting for handshake_ack') + }) + + it('should call onTimeout callback when timeout occurs', async () => { + const onTimeout = vi.fn() + manager.setTimeoutCallback(onTimeout) + + const resolvePromise = new Promise((resolve, reject) => { + manager.waitForResponse('test', 1000, resolve, reject) + }) + + vi.advanceTimersByTime(1001) + + await expect(resolvePromise).rejects.toThrow() + expect(onTimeout).toHaveBeenCalled() + }) + + it('should reject when abort signal is triggered', async () => { + const abortController = new AbortController() + + const resolvePromise = new Promise((resolve, reject) => { + manager.waitForResponse('test', 10000, resolve, reject, undefined, undefined, abortController.signal) + }) + + abortController.abort(new Error('User cancelled')) + + await expect(resolvePromise).rejects.toThrow('User cancelled') + }) + + it('should replace existing response with same key', async () => { + const firstReject = vi.fn() + const secondResolve = vi.fn() + const secondReject = vi.fn() + + manager.waitForResponse('test', 5000, vi.fn(), firstReject) + manager.waitForResponse('test', 5000, secondResolve, secondReject) + + // First should be cleared (no rejection since it's replaced) + const payload = { type: 'test' } + manager.tryResolve('test', payload) + + expect(secondResolve).toHaveBeenCalledWith(payload) + }) + }) + + describe('tryResolve', () => { + it('should return false when no matching response', () => { + expect(manager.tryResolve('nonexistent', {})).toBe(false) + }) + + it('should match with transferId', async () => { + const resolvePromise = new Promise((resolve, reject) => { + manager.waitForResponse('file_start_ack', 5000, resolve, reject, 'uuid-123') + }) + + const payload = { type: 'file_start_ack', transferId: 'uuid-123' } + manager.tryResolve('file_start_ack', payload, 'uuid-123') + + await expect(resolvePromise).resolves.toEqual(payload) + }) + }) + + describe('rejectAll', () => { + it('should reject all pending responses', async () => { + const promises = [ + new Promise((resolve, reject) => { + manager.waitForResponse('test1', 5000, resolve, reject) + }), + new Promise((resolve, reject) => { + manager.waitForResponse('test2', 5000, resolve, reject, 'uuid') + }) + ] + + manager.rejectAll(new Error('Connection closed')) + + await expect(promises[0]).rejects.toThrow('Connection closed') + await expect(promises[1]).rejects.toThrow('Connection closed') + }) + }) + + describe('clearPendingResponse', () => { + it('should clear specific response by key', () => { + manager.waitForResponse('test', 5000, vi.fn(), vi.fn()) + + manager.clearPendingResponse('test') + + expect(manager.tryResolve('test', {})).toBe(false) + }) + + it('should clear all responses when no key provided', () => { + manager.waitForResponse('test1', 5000, vi.fn(), vi.fn()) + manager.waitForResponse('test2', 5000, vi.fn(), vi.fn()) + + manager.clearPendingResponse() + + expect(manager.tryResolve('test1', {})).toBe(false) + expect(manager.tryResolve('test2', {})).toBe(false) + }) + }) + + describe('getAbortError', () => { + it('should return Error reason directly', () => { + const originalError = new Error('Original error') + const signal = { aborted: true, reason: originalError } as AbortSignal + + const error = manager.getAbortError(signal, 'Fallback') + + expect(error).toBe(originalError) + }) + + it('should create Error from string reason', () => { + const signal = { aborted: true, reason: 'String reason' } as AbortSignal + + const error = manager.getAbortError(signal, 'Fallback') + + expect(error.message).toBe('String reason') + }) + + it('should use fallback message when no reason', () => { + const signal = { aborted: true } as AbortSignal + + const error = manager.getAbortError(signal, 'Fallback message') + + expect(error.message).toBe('Fallback message') + }) + }) +}) diff --git a/src/main/services/lanTransfer/binaryProtocol.ts b/src/main/services/lanTransfer/binaryProtocol.ts new file mode 100644 index 000000000..a3e16e776 --- /dev/null +++ b/src/main/services/lanTransfer/binaryProtocol.ts @@ -0,0 +1,63 @@ +import type { Socket } from 'node:net' + +/** + * Binary protocol constants (v3) + */ +export const BINARY_TYPE_FILE_CHUNK = 0x01 + +/** + * Send file chunk as binary frame (protocol v3 - streaming mode) + * + * Frame format: + * ``` + * ┌──────────┬──────────┬──────────┬───────────────┬──────────────┬────────────┬───────────┐ + * │ Magic │ TotalLen │ Type │ TransferId Len│ TransferId │ ChunkIdx │ Data │ + * │ 0x43 0x53│ (4B BE) │ 0x01 │ (2B BE) │ (variable) │ (4B BE) │ (raw) │ + * └──────────┴──────────┴──────────┴───────────────┴──────────────┴────────────┴───────────┘ + * ``` + * + * @param socket - TCP socket to write to + * @param transferId - UUID of the transfer + * @param chunkIndex - Index of the chunk (0-based) + * @param data - Raw chunk data buffer + * @returns true if data was buffered, false if backpressure should be applied + */ +export function sendBinaryChunk(socket: Socket, transferId: string, chunkIndex: number, data: Buffer): boolean { + const tidBuffer = Buffer.from(transferId, 'utf8') + const tidLen = tidBuffer.length + + // totalLen = type(1) + tidLen(2) + tid(n) + idx(4) + data(m) + const totalLen = 1 + 2 + tidLen + 4 + data.length + + const header = Buffer.allocUnsafe(2 + 4 + 1 + 2 + tidLen + 4) + let offset = 0 + + // Magic (2 bytes): "CS" + header[offset++] = 0x43 + header[offset++] = 0x53 + + // TotalLen (4 bytes, Big-Endian) + header.writeUInt32BE(totalLen, offset) + offset += 4 + + // Type (1 byte) + header[offset++] = BINARY_TYPE_FILE_CHUNK + + // TransferId length (2 bytes, Big-Endian) + header.writeUInt16BE(tidLen, offset) + offset += 2 + + // TransferId (variable) + tidBuffer.copy(header, offset) + offset += tidLen + + // ChunkIndex (4 bytes, Big-Endian) + header.writeUInt32BE(chunkIndex, offset) + + socket.cork() + const wroteHeader = socket.write(header) + const wroteData = socket.write(data) + socket.uncork() + + return wroteHeader && wroteData +} diff --git a/src/main/services/lanTransfer/handlers/connection.ts b/src/main/services/lanTransfer/handlers/connection.ts new file mode 100644 index 000000000..14340c74a --- /dev/null +++ b/src/main/services/lanTransfer/handlers/connection.ts @@ -0,0 +1,151 @@ +import { isIP, type Socket } from 'node:net' +import { platform } from 'node:os' + +import { loggerService } from '@logger' +import type { LanHandshakeRequestMessage, LocalTransferPeer } from '@shared/config/types' +import { app } from 'electron' + +import type { ConnectionContext } from '../types' + +export const HANDSHAKE_PROTOCOL_VERSION = '3' + +const logger = loggerService.withContext('LanTransferConnection') + +/** + * Build a handshake request message with device info. + */ +export function buildHandshakeMessage(): LanHandshakeRequestMessage { + return { + type: 'handshake', + deviceName: app.getName(), + version: HANDSHAKE_PROTOCOL_VERSION, + platform: platform(), + appVersion: app.getVersion() + } +} + +/** + * Pick the best host address from a peer's available addresses. + * Prefers IPv4 addresses over IPv6. + */ +export function pickHost(peer: LocalTransferPeer): string | undefined { + const preferred = peer.addresses?.find((addr) => isIP(addr) === 4) || peer.addresses?.[0] + return preferred || peer.host +} + +/** + * Send a test ping message after successful handshake. + */ +export function sendTestPing(ctx: ConnectionContext): void { + const payload = 'hello world' + try { + ctx.sendControlMessage({ type: 'ping', payload }) + logger.info('Sent LAN ping test payload') + ctx.broadcastClientEvent({ + type: 'ping_sent', + payload, + timestamp: Date.now() + }) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + logger.error('Failed to send LAN test ping', error as Error) + ctx.broadcastClientEvent({ + type: 'error', + message, + timestamp: Date.now() + }) + } +} + +/** + * Attach data listener to socket for receiving control messages. + * Returns a function to parse the line buffer. + */ +export function createDataHandler(onControlLine: (line: string) => void): { + lineBuffer: string + handleData: (chunk: Buffer) => void + resetBuffer: () => void +} { + let lineBuffer = '' + + return { + get lineBuffer() { + return lineBuffer + }, + handleData(chunk: Buffer) { + lineBuffer += chunk.toString('utf8') + let newlineIndex = lineBuffer.indexOf('\n') + while (newlineIndex !== -1) { + const line = lineBuffer.slice(0, newlineIndex).trim() + lineBuffer = lineBuffer.slice(newlineIndex + 1) + if (line.length > 0) { + onControlLine(line) + } + newlineIndex = lineBuffer.indexOf('\n') + } + }, + resetBuffer() { + lineBuffer = '' + } + } +} + +/** + * Wait for socket to drain (backpressure handling). + */ +export async function waitForSocketDrain(socket: Socket, abortSignal: AbortSignal): Promise { + if (abortSignal.aborted) { + throw getAbortError(abortSignal, 'Transfer aborted while waiting for socket drain') + } + if (socket.destroyed) { + throw new Error('Socket is closed') + } + + await new Promise((resolve, reject) => { + const cleanup = () => { + socket.off('drain', onDrain) + socket.off('close', onClose) + socket.off('error', onError) + abortSignal.removeEventListener('abort', onAbort) + } + + const onDrain = () => { + cleanup() + resolve() + } + + const onClose = () => { + cleanup() + reject(new Error('Socket closed while waiting for drain')) + } + + const onError = (error: Error) => { + cleanup() + reject(error) + } + + const onAbort = () => { + cleanup() + reject(getAbortError(abortSignal, 'Transfer aborted while waiting for socket drain')) + } + + socket.once('drain', onDrain) + socket.once('close', onClose) + socket.once('error', onError) + abortSignal.addEventListener('abort', onAbort, { once: true }) + }) +} + +/** + * Get the error from an abort signal, or create a fallback error. + */ +export function getAbortError(signal: AbortSignal, fallbackMessage: string): Error { + const reason = (signal as AbortSignal & { reason?: unknown }).reason + if (reason instanceof Error) { + return reason + } + if (typeof reason === 'string' && reason.length > 0) { + return new Error(reason) + } + return new Error(fallbackMessage) +} diff --git a/src/main/services/lanTransfer/handlers/fileTransfer.ts b/src/main/services/lanTransfer/handlers/fileTransfer.ts new file mode 100644 index 000000000..733de9ec6 --- /dev/null +++ b/src/main/services/lanTransfer/handlers/fileTransfer.ts @@ -0,0 +1,258 @@ +import * as crypto from 'node:crypto' +import * as fs from 'node:fs' +import type { Socket } from 'node:net' +import * as path from 'node:path' + +import { loggerService } from '@logger' +import type { + LanFileCompleteMessage, + LanFileEndMessage, + LanFileStartAckMessage, + LanFileStartMessage +} from '@shared/config/types' +import { + LAN_TRANSFER_CHUNK_SIZE, + LAN_TRANSFER_COMPLETE_TIMEOUT_MS, + LAN_TRANSFER_MAX_FILE_SIZE +} from '@shared/config/types' + +import { sendBinaryChunk } from '../binaryProtocol' +import type { ActiveFileTransfer, FileTransferContext } from '../types' +import { getAbortError, waitForSocketDrain } from './connection' + +const DEFAULT_FILE_START_ACK_TIMEOUT_MS = 30_000 // 30s for file_start_ack + +const logger = loggerService.withContext('LanTransferFileHandler') + +/** + * Validate a file for transfer. + * Checks existence, type, extension, and size limits. + */ +export async function validateFile(filePath: string): Promise<{ stats: fs.Stats; fileName: string }> { + let stats: fs.Stats + try { + stats = await fs.promises.stat(filePath) + } catch { + throw new Error(`File not found: ${filePath}`) + } + + if (!stats.isFile()) { + throw new Error('Path is not a file') + } + + const fileName = path.basename(filePath) + const ext = path.extname(fileName).toLowerCase() + if (ext !== '.zip') { + throw new Error('Only ZIP files are supported') + } + + if (stats.size > LAN_TRANSFER_MAX_FILE_SIZE) { + throw new Error(`File too large. Maximum size is ${formatFileSize(LAN_TRANSFER_MAX_FILE_SIZE)}`) + } + + return { stats, fileName } +} + +/** + * Calculate SHA-256 checksum of a file. + */ +export async function calculateFileChecksum(filePath: string): Promise { + return new Promise((resolve, reject) => { + const hash = crypto.createHash('sha256') + const stream = fs.createReadStream(filePath) + stream.on('data', (data) => hash.update(data)) + stream.on('end', () => resolve(hash.digest('hex'))) + stream.on('error', reject) + }) +} + +/** + * Create initial transfer state for a new file transfer. + */ +export function createTransferState( + transferId: string, + fileName: string, + fileSize: number, + checksum: string +): ActiveFileTransfer { + const chunkSize = LAN_TRANSFER_CHUNK_SIZE + const totalChunks = Math.ceil(fileSize / chunkSize) + + return { + transferId, + fileName, + fileSize, + checksum, + totalChunks, + chunkSize, + bytesSent: 0, + currentChunk: 0, + startedAt: Date.now(), + isCancelled: false, + abortController: new AbortController() + } +} + +/** + * Send file_start message to receiver. + */ +export function sendFileStart(ctx: FileTransferContext, transfer: ActiveFileTransfer): void { + const startMessage: LanFileStartMessage = { + type: 'file_start', + transferId: transfer.transferId, + fileName: transfer.fileName, + fileSize: transfer.fileSize, + mimeType: 'application/zip', + checksum: transfer.checksum, + totalChunks: transfer.totalChunks, + chunkSize: transfer.chunkSize + } + ctx.sendControlMessage(startMessage) + logger.info('Sent file_start message') +} + +/** + * Wait for file_start_ack from receiver. + */ +export function waitForFileStartAck( + ctx: FileTransferContext, + transferId: string, + abortSignal?: AbortSignal +): Promise { + return new Promise((resolve, reject) => { + ctx.waitForResponse( + 'file_start_ack', + DEFAULT_FILE_START_ACK_TIMEOUT_MS, + (payload) => resolve(payload as LanFileStartAckMessage), + reject, + transferId, + undefined, + abortSignal + ) + }) +} + +/** + * Wait for file_complete from receiver after all chunks sent. + */ +export function waitForFileComplete( + ctx: FileTransferContext, + transferId: string, + abortSignal?: AbortSignal +): Promise { + return new Promise((resolve, reject) => { + ctx.waitForResponse( + 'file_complete', + LAN_TRANSFER_COMPLETE_TIMEOUT_MS, + (payload) => resolve(payload as LanFileCompleteMessage), + reject, + transferId, + undefined, + abortSignal + ) + }) +} + +/** + * Send file_end message to receiver. + */ +export function sendFileEnd(ctx: FileTransferContext, transferId: string): void { + const endMessage: LanFileEndMessage = { + type: 'file_end', + transferId + } + ctx.sendControlMessage(endMessage) + logger.info('Sent file_end message') +} + +/** + * Stream file chunks to the receiver (v3 streaming mode - no per-chunk acknowledgment). + */ +export async function streamFileChunks( + socket: Socket, + filePath: string, + transfer: ActiveFileTransfer, + abortSignal: AbortSignal, + onProgress: (bytesSent: number, chunkIndex: number) => void +): Promise { + const { chunkSize, transferId } = transfer + + const stream = fs.createReadStream(filePath, { highWaterMark: chunkSize }) + transfer.stream = stream + + let chunkIndex = 0 + let bytesSent = 0 + + try { + for await (const chunk of stream) { + if (abortSignal.aborted) { + throw getAbortError(abortSignal, 'Transfer aborted') + } + + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) + bytesSent += buffer.length + + // Send chunk as binary frame (v3 streaming) with backpressure handling + const canContinue = sendBinaryChunk(socket, transferId, chunkIndex, buffer) + if (!canContinue) { + await waitForSocketDrain(socket, abortSignal) + } + + // Update progress + transfer.bytesSent = bytesSent + transfer.currentChunk = chunkIndex + + onProgress(bytesSent, chunkIndex) + chunkIndex++ + } + + logger.info(`File streaming completed: ${chunkIndex} chunks sent`) + } catch (error) { + logger.error('File streaming failed', error as Error) + throw error + } +} + +/** + * Abort an active transfer and clean up resources. + */ +export function abortTransfer(transfer: ActiveFileTransfer | undefined, error: Error): void { + if (!transfer) { + return + } + + transfer.isCancelled = true + if (!transfer.abortController.signal.aborted) { + transfer.abortController.abort(error) + } + if (transfer.stream && !transfer.stream.destroyed) { + transfer.stream.destroy(error) + } +} + +/** + * Clean up transfer resources without error. + */ +export function cleanupTransfer(transfer: ActiveFileTransfer | undefined): void { + if (!transfer) { + return + } + + if (!transfer.abortController.signal.aborted) { + transfer.abortController.abort() + } + if (transfer.stream && !transfer.stream.destroyed) { + transfer.stream.destroy() + } +} + +/** + * Format bytes into human-readable size string. + */ +export function formatFileSize(bytes: number): string { + if (bytes === 0) return '0 B' + const k = 1024 + const sizes = ['B', 'KB', 'MB', 'GB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] +} diff --git a/src/main/services/lanTransfer/handlers/index.ts b/src/main/services/lanTransfer/handlers/index.ts new file mode 100644 index 000000000..33620d188 --- /dev/null +++ b/src/main/services/lanTransfer/handlers/index.ts @@ -0,0 +1,22 @@ +export { + buildHandshakeMessage, + createDataHandler, + getAbortError, + HANDSHAKE_PROTOCOL_VERSION, + pickHost, + sendTestPing, + waitForSocketDrain +} from './connection' +export { + abortTransfer, + calculateFileChecksum, + cleanupTransfer, + createTransferState, + formatFileSize, + sendFileEnd, + sendFileStart, + streamFileChunks, + validateFile, + waitForFileComplete, + waitForFileStartAck +} from './fileTransfer' diff --git a/src/main/services/lanTransfer/index.ts b/src/main/services/lanTransfer/index.ts new file mode 100644 index 000000000..ad6cd2862 --- /dev/null +++ b/src/main/services/lanTransfer/index.ts @@ -0,0 +1,21 @@ +/** + * LAN Transfer Client Module + * + * Protocol: v3.0 (streaming mode) + * + * Features: + * - Binary frame format for file chunks (no base64 overhead) + * - Streaming mode (no per-chunk acknowledgment) + * - JSON messages for control flow (handshake, file_start, file_end, etc.) + * - Global timeout protection + * - Backpressure handling + * + * Binary Frame Format: + * ┌──────────┬──────────┬──────────┬───────────────┬──────────────┬────────────┬───────────┐ + * │ Magic │ TotalLen │ Type │ TransferId Len│ TransferId │ ChunkIdx │ Data │ + * │ 0x43 0x53│ (4B BE) │ 0x01 │ (2B BE) │ (variable) │ (4B BE) │ (raw) │ + * └──────────┴──────────┴──────────┴───────────────┴──────────────┴────────────┴───────────┘ + */ + +export { HANDSHAKE_PROTOCOL_VERSION, lanTransferClientService } from './LanTransferClientService' +export type { ActiveFileTransfer, ConnectionContext, FileTransferContext, PendingResponse } from './types' diff --git a/src/main/services/lanTransfer/responseManager.ts b/src/main/services/lanTransfer/responseManager.ts new file mode 100644 index 000000000..74d5196db --- /dev/null +++ b/src/main/services/lanTransfer/responseManager.ts @@ -0,0 +1,144 @@ +import type { PendingResponse } from './types' + +/** + * Manages pending response handlers for awaiting control messages. + * Handles timeouts, abort signals, and cleanup. + */ +export class ResponseManager { + private pendingResponses = new Map() + private onTimeout?: () => void + + /** + * Set a callback to be called when a response times out. + * Typically used to trigger disconnect on timeout. + */ + setTimeoutCallback(callback: () => void): void { + this.onTimeout = callback + } + + /** + * Build a composite key for identifying pending responses. + */ + buildResponseKey(type: string, transferId?: string, chunkIndex?: number): string { + const parts = [type] + if (transferId !== undefined) parts.push(transferId) + if (chunkIndex !== undefined) parts.push(String(chunkIndex)) + return parts.join(':') + } + + /** + * Register a response listener with timeout and optional abort signal. + */ + waitForResponse( + type: string, + timeoutMs: number, + resolve: (payload: unknown) => void, + reject: (error: Error) => void, + transferId?: string, + chunkIndex?: number, + abortSignal?: AbortSignal + ): void { + const responseKey = this.buildResponseKey(type, transferId, chunkIndex) + + // Clear any existing response with the same key + this.clearPendingResponse(responseKey) + + const timeoutHandle = setTimeout(() => { + this.clearPendingResponse(responseKey) + const error = new Error(`Timeout waiting for ${type}`) + reject(error) + this.onTimeout?.() + }, timeoutMs) + + const pending: PendingResponse = { + type, + transferId, + chunkIndex, + resolve, + reject, + timeoutHandle, + abortSignal + } + + if (abortSignal) { + const abortListener = () => { + this.clearPendingResponse(responseKey) + reject(this.getAbortError(abortSignal, `Aborted while waiting for ${type}`)) + } + pending.abortListener = abortListener + abortSignal.addEventListener('abort', abortListener, { once: true }) + } + + this.pendingResponses.set(responseKey, pending) + } + + /** + * Try to resolve a pending response by type and optional identifiers. + * Returns true if a matching response was found and resolved. + */ + tryResolve(type: string, payload: unknown, transferId?: string, chunkIndex?: number): boolean { + const responseKey = this.buildResponseKey(type, transferId, chunkIndex) + const pendingResponse = this.pendingResponses.get(responseKey) + + if (pendingResponse) { + const resolver = pendingResponse.resolve + this.clearPendingResponse(responseKey) + resolver(payload) + return true + } + + return false + } + + /** + * Clear a single pending response by key, or all responses if no key provided. + */ + clearPendingResponse(key?: string): void { + if (key) { + const pending = this.pendingResponses.get(key) + if (pending?.timeoutHandle) { + clearTimeout(pending.timeoutHandle) + } + if (pending?.abortSignal && pending.abortListener) { + pending.abortSignal.removeEventListener('abort', pending.abortListener) + } + this.pendingResponses.delete(key) + } else { + // Clear all pending responses + for (const pending of this.pendingResponses.values()) { + if (pending.timeoutHandle) { + clearTimeout(pending.timeoutHandle) + } + if (pending.abortSignal && pending.abortListener) { + pending.abortSignal.removeEventListener('abort', pending.abortListener) + } + } + this.pendingResponses.clear() + } + } + + /** + * Reject all pending responses with the given error. + */ + rejectAll(error: Error): void { + for (const key of Array.from(this.pendingResponses.keys())) { + const pending = this.pendingResponses.get(key) + this.clearPendingResponse(key) + pending?.reject(error) + } + } + + /** + * Get the abort error from an abort signal, or create a fallback error. + */ + getAbortError(signal: AbortSignal, fallbackMessage: string): Error { + const reason = (signal as AbortSignal & { reason?: unknown }).reason + if (reason instanceof Error) { + return reason + } + if (typeof reason === 'string' && reason.length > 0) { + return new Error(reason) + } + return new Error(fallbackMessage) + } +} diff --git a/src/main/services/lanTransfer/types.ts b/src/main/services/lanTransfer/types.ts new file mode 100644 index 000000000..52be660af --- /dev/null +++ b/src/main/services/lanTransfer/types.ts @@ -0,0 +1,65 @@ +import type * as fs from 'node:fs' +import type { Socket } from 'node:net' + +import type { LanClientEvent, LocalTransferPeer } from '@shared/config/types' + +/** + * Pending response handler for awaiting control messages + */ +export type PendingResponse = { + type: string + transferId?: string + chunkIndex?: number + resolve: (payload: unknown) => void + reject: (error: Error) => void + timeoutHandle?: NodeJS.Timeout + abortSignal?: AbortSignal + abortListener?: () => void +} + +/** + * Active file transfer state tracking + */ +export type ActiveFileTransfer = { + transferId: string + fileName: string + fileSize: number + checksum: string + totalChunks: number + chunkSize: number + bytesSent: number + currentChunk: number + startedAt: number + stream?: fs.ReadStream + isCancelled: boolean + abortController: AbortController +} + +/** + * Context interface for connection handlers + * Provides access to service methods without circular dependencies + */ +export type ConnectionContext = { + socket: Socket | null + currentPeer?: LocalTransferPeer + sendControlMessage: (message: Record) => void + broadcastClientEvent: (event: LanClientEvent) => void +} + +/** + * Context interface for file transfer handlers + * Extends connection context with transfer-specific methods + */ +export type FileTransferContext = ConnectionContext & { + activeTransfer?: ActiveFileTransfer + setActiveTransfer: (transfer: ActiveFileTransfer | undefined) => void + waitForResponse: ( + type: string, + timeoutMs: number, + resolve: (payload: unknown) => void, + reject: (error: Error) => void, + transferId?: string, + chunkIndex?: number, + abortSignal?: AbortSignal + ) => void +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 25b1064d4..741a7b895 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -4,7 +4,15 @@ import type { SpanEntity, TokenUsage } from '@mcp-trace/trace-core' import type { SpanContext } from '@opentelemetry/api' import type { TerminalConfig, UpgradeChannel } from '@shared/config/constant' import type { LogLevel, LogSourceWithContext } from '@shared/config/logger' -import type { FileChangeEvent, WebviewKeyEvent } from '@shared/config/types' +import type { + FileChangeEvent, + LanClientEvent, + LanFileCompleteMessage, + LanHandshakeAckMessage, + LocalTransferConnectPayload, + LocalTransferState, + WebviewKeyEvent +} from '@shared/config/types' import { IpcChannel } from '@shared/IpcChannel' import type { Notification } from '@types' import type { @@ -167,7 +175,11 @@ const api = { listS3Files: (s3Config: S3Config) => ipcRenderer.invoke(IpcChannel.Backup_ListS3Files, s3Config), deleteS3File: (fileName: string, s3Config: S3Config) => ipcRenderer.invoke(IpcChannel.Backup_DeleteS3File, fileName, s3Config), - checkS3Connection: (s3Config: S3Config) => ipcRenderer.invoke(IpcChannel.Backup_CheckS3Connection, s3Config) + checkS3Connection: (s3Config: S3Config) => ipcRenderer.invoke(IpcChannel.Backup_CheckS3Connection, s3Config), + createLanTransferBackup: (data: string): Promise => + ipcRenderer.invoke(IpcChannel.Backup_CreateLanTransferBackup, data), + deleteTempBackup: (filePath: string): Promise => + ipcRenderer.invoke(IpcChannel.Backup_DeleteTempBackup, filePath) }, file: { select: (options?: OpenDialogOptions): Promise => @@ -570,6 +582,33 @@ const api = { writeContent: (options: WritePluginContentOptions): Promise> => ipcRenderer.invoke(IpcChannel.ClaudeCodePlugin_WriteContent, options) }, + localTransfer: { + getState: (): Promise => ipcRenderer.invoke(IpcChannel.LocalTransfer_ListServices), + startScan: (): Promise => ipcRenderer.invoke(IpcChannel.LocalTransfer_StartScan), + stopScan: (): Promise => ipcRenderer.invoke(IpcChannel.LocalTransfer_StopScan), + connect: (payload: LocalTransferConnectPayload): Promise => + ipcRenderer.invoke(IpcChannel.LocalTransfer_Connect, payload), + disconnect: (): Promise => ipcRenderer.invoke(IpcChannel.LocalTransfer_Disconnect), + onServicesUpdated: (callback: (state: LocalTransferState) => void): (() => void) => { + const channel = IpcChannel.LocalTransfer_ServicesUpdated + const listener = (_: Electron.IpcRendererEvent, state: LocalTransferState) => callback(state) + ipcRenderer.on(channel, listener) + return () => { + ipcRenderer.removeListener(channel, listener) + } + }, + onClientEvent: (callback: (event: LanClientEvent) => void): (() => void) => { + const channel = IpcChannel.LocalTransfer_ClientEvent + const listener = (_: Electron.IpcRendererEvent, event: LanClientEvent) => callback(event) + ipcRenderer.on(channel, listener) + return () => { + ipcRenderer.removeListener(channel, listener) + } + }, + sendFile: (filePath: string): Promise => + ipcRenderer.invoke(IpcChannel.LocalTransfer_SendFile, { filePath }), + cancelTransfer: (): Promise => ipcRenderer.invoke(IpcChannel.LocalTransfer_CancelTransfer) + }, webSocket: { start: () => ipcRenderer.invoke(IpcChannel.WebSocket_Start), stop: () => ipcRenderer.invoke(IpcChannel.WebSocket_Stop), diff --git a/src/renderer/src/components/Popups/ExportToPhoneLanPopup.tsx b/src/renderer/src/components/Popups/ExportToPhoneLanPopup.tsx deleted file mode 100644 index cbe51ac61..000000000 --- a/src/renderer/src/components/Popups/ExportToPhoneLanPopup.tsx +++ /dev/null @@ -1,553 +0,0 @@ -import { loggerService } from '@logger' -import { AppLogo } from '@renderer/config/env' -import { SettingHelpText, SettingRow } from '@renderer/pages/settings' -import type { WebSocketCandidatesResponse } from '@shared/config/types' -import { Alert, Button, Modal, Progress, Spin } from 'antd' -import { QRCodeSVG } from 'qrcode.react' -import { useCallback, useEffect, useMemo, useState } from 'react' -import { useTranslation } from 'react-i18next' - -import { TopView } from '../TopView' - -const logger = loggerService.withContext('ExportToPhoneLanPopup') - -interface Props { - resolve: (data: any) => void -} - -type ConnectionPhase = 'initializing' | 'waiting_qr_scan' | 'connecting' | 'connected' | 'disconnected' | 'error' -type TransferPhase = 'idle' | 'preparing' | 'sending' | 'completed' | 'error' - -const LoadingQRCode: React.FC = () => { - const { t } = useTranslation() - return ( -
- - - {t('settings.data.export_to_phone.lan.generating_qr')} - -
- ) -} - -const ScanQRCode: React.FC<{ qrCodeValue: string }> = ({ qrCodeValue }) => { - const { t } = useTranslation() - return ( -
- - - {t('settings.data.export_to_phone.lan.scan_qr')} - -
- ) -} - -const ConnectingAnimation: React.FC = () => { - const { t } = useTranslation() - return ( -
-
- - - {t('settings.data.export_to_phone.lan.status.connecting')} - -
-
- ) -} - -const ConnectedDisplay: React.FC = () => { - const { t } = useTranslation() - return ( -
-
- 📱 - - {t('settings.data.export_to_phone.lan.connected')} - -
-
- ) -} - -const ErrorQRCode: React.FC<{ error: string | null }> = ({ error }) => { - const { t } = useTranslation() - return ( -
- ⚠️ - - {t('settings.data.export_to_phone.lan.connection_failed')} - - {error && {error}} -
- ) -} - -const PopupContainer: React.FC = ({ resolve }) => { - const [isOpen, setIsOpen] = useState(true) - const [connectionPhase, setConnectionPhase] = useState('initializing') - const [transferPhase, setTransferPhase] = useState('idle') - const [qrCodeValue, setQrCodeValue] = useState('') - const [selectedFolderPath, setSelectedFolderPath] = useState(null) - const [sendProgress, setSendProgress] = useState(0) - const [error, setError] = useState(null) - const [autoCloseCountdown, setAutoCloseCountdown] = useState(null) - - const { t } = useTranslation() - - // 派生状态 - const isConnected = connectionPhase === 'connected' - const canSend = isConnected && selectedFolderPath && transferPhase === 'idle' - const isSending = transferPhase === 'preparing' || transferPhase === 'sending' - - // 状态文本映射 - const connectionStatusText = useMemo(() => { - const statusMap = { - initializing: t('settings.data.export_to_phone.lan.status.initializing'), - waiting_qr_scan: t('settings.data.export_to_phone.lan.status.waiting_qr_scan'), - connecting: t('settings.data.export_to_phone.lan.status.connecting'), - connected: t('settings.data.export_to_phone.lan.status.connected'), - disconnected: t('settings.data.export_to_phone.lan.status.disconnected'), - error: t('settings.data.export_to_phone.lan.status.error') - } - return statusMap[connectionPhase] - }, [connectionPhase, t]) - - const transferStatusText = useMemo(() => { - const statusMap = { - idle: '', - preparing: t('settings.data.export_to_phone.lan.status.preparing'), - sending: t('settings.data.export_to_phone.lan.status.sending'), - completed: t('settings.data.export_to_phone.lan.status.completed'), - error: t('settings.data.export_to_phone.lan.status.error') - } - return statusMap[transferPhase] - }, [transferPhase, t]) - - // 状态样式映射 - const connectionStatusStyles = useMemo(() => { - const styleMap = { - initializing: { - bg: 'var(--color-background-mute)', - border: 'var(--color-border-mute)' - }, - waiting_qr_scan: { - bg: 'var(--color-primary-mute)', - border: 'var(--color-primary-soft)' - }, - connecting: { bg: 'var(--color-status-warning)', border: 'var(--color-status-warning)' }, - connected: { - bg: 'var(--color-status-success)', - border: 'var(--color-status-success)' - }, - disconnected: { bg: 'var(--color-error)', border: 'var(--color-error)' }, - error: { bg: 'var(--color-error)', border: 'var(--color-error)' } - } - return styleMap[connectionPhase] - }, [connectionPhase]) - - const initWebSocket = useCallback(async () => { - try { - setConnectionPhase('initializing') - await window.api.webSocket.start() - const { port, ip } = await window.api.webSocket.status() - - if (ip && port) { - const candidatesData = await window.api.webSocket.getAllCandidates() - - const optimizeConnectionInfo = () => { - const ipToNumber = (ip: string) => { - return ip.split('.').reduce((acc, octet) => (acc << 8) + parseInt(octet), 0) - } - - const compressedData = [ - 'CSA', - ipToNumber(ip), - candidatesData.map((candidate: WebSocketCandidatesResponse) => ipToNumber(candidate.host)), - port, // 端口号 - Date.now() % 86400000 - ] - - return compressedData - } - - const compressedData = optimizeConnectionInfo() - const qrCodeValue = JSON.stringify(compressedData) - setQrCodeValue(qrCodeValue) - setConnectionPhase('waiting_qr_scan') - } else { - setError(t('settings.data.export_to_phone.lan.error.no_ip')) - setConnectionPhase('error') - } - } catch (error) { - setError( - `${t('settings.data.export_to_phone.lan.error.init_failed')}: ${error instanceof Error ? error.message : ''}` - ) - setConnectionPhase('error') - logger.error('Failed to initialize WebSocket:', error as Error) - } - }, [t]) - - const handleClientConnected = useCallback((_event: any, data: { connected: boolean }) => { - logger.info(`Client connection status: ${data.connected ? 'connected' : 'disconnected'}`) - if (data.connected) { - setConnectionPhase('connected') - setError(null) - } else { - setConnectionPhase('disconnected') - } - }, []) - - const handleMessageReceived = useCallback((_event: any, data: any) => { - logger.info(`Received message from mobile: ${JSON.stringify(data)}`) - }, []) - - const handleSendProgress = useCallback( - (_event: any, data: { progress: number }) => { - const progress = data.progress - setSendProgress(progress) - - if (transferPhase === 'preparing' && progress > 0) { - setTransferPhase('sending') - } - - if (progress >= 100) { - setTransferPhase('completed') - // 启动 3 秒倒计时自动关闭 - setAutoCloseCountdown(3) - } - }, - [transferPhase] - ) - - const handleSelectZip = useCallback(async () => { - const result = await window.api.file.select() - if (result) { - setSelectedFolderPath(result[0].path) - } - }, []) - - const handleSendZip = useCallback(async () => { - if (!selectedFolderPath) { - setError(t('settings.data.export_to_phone.lan.error.no_file')) - return - } - - setTransferPhase('preparing') - setError(null) - setSendProgress(0) - - try { - logger.info(`Starting file transfer: ${selectedFolderPath}`) - await window.api.webSocket.sendFile(selectedFolderPath) - } catch (error) { - setError( - `${t('settings.data.export_to_phone.lan.error.send_failed')}: ${error instanceof Error ? error.message : ''}` - ) - setTransferPhase('error') - logger.error('Failed to send file:', error as Error) - } - }, [selectedFolderPath, t]) - - // 尝试关闭弹窗 - 如果正在传输则显示确认 - const handleCancel = useCallback(() => { - if (isSending) { - window.modal.confirm({ - title: t('settings.data.export_to_phone.lan.confirm_close_title'), - content: t('settings.data.export_to_phone.lan.confirm_close_message'), - centered: true, - okButtonProps: { - danger: true - }, - okText: t('settings.data.export_to_phone.lan.force_close'), - onOk: () => setIsOpen(false) - }) - } else { - setIsOpen(false) - } - }, [isSending, t]) - - // 清理并关闭 - const handleClose = useCallback(async () => { - try { - // 主动断开 WebSocket 连接 - if (isConnected || connectionPhase !== 'disconnected') { - logger.info('Closing popup, stopping WebSocket') - await window.api.webSocket.stop() - } - } catch (error) { - logger.error('Failed to stop WebSocket on close:', error as Error) - } - resolve({}) - }, [resolve, isConnected, connectionPhase]) - - useEffect(() => { - initWebSocket() - - const removeClientConnectedListener = window.electron.ipcRenderer.on( - 'websocket-client-connected', - handleClientConnected - ) - const removeMessageReceivedListener = window.electron.ipcRenderer.on( - 'websocket-message-received', - handleMessageReceived - ) - const removeSendProgressListener = window.electron.ipcRenderer.on('file-send-progress', handleSendProgress) - - return () => { - removeClientConnectedListener() - removeMessageReceivedListener() - removeSendProgressListener() - window.api.webSocket.stop() - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) - - // 自动关闭倒计时 - useEffect(() => { - if (autoCloseCountdown === null) return - - if (autoCloseCountdown <= 0) { - logger.debug('Auto-closing popup after transfer completion') - setIsOpen(false) - return - } - - const timer = setTimeout(() => { - setAutoCloseCountdown(autoCloseCountdown - 1) - }, 1000) - - return () => clearTimeout(timer) - }, [autoCloseCountdown]) - - // 状态指示器组件 - const StatusIndicator = useCallback( - () => ( -
- {connectionStatusText} -
- ), - [connectionStatusStyles, connectionStatusText] - ) - - // 二维码显示组件 - 使用显式条件渲染以避免类型不匹配 - const QRCodeDisplay = useCallback(() => { - switch (connectionPhase) { - case 'waiting_qr_scan': - case 'disconnected': - return - case 'initializing': - return - case 'connecting': - return - case 'connected': - return - case 'error': - return - default: - return null - } - }, [connectionPhase, qrCodeValue, error]) - - // 传输进度组件 - const TransferProgress = useCallback(() => { - if (!isSending && transferPhase !== 'completed') return null - - return ( -
-
-
- - {t('settings.data.export_to_phone.lan.transfer_progress')} - - - {transferPhase === 'completed' ? '✅ ' + t('common.completed') : `${Math.round(sendProgress)}%`} - -
- - -
-
- ) - }, [isSending, transferPhase, sendProgress, t]) - - const AutoCloseCountdown = useCallback(() => { - if (transferPhase !== 'completed' || autoCloseCountdown === null || autoCloseCountdown <= 0) return null - - return ( -
- {t('settings.data.export_to_phone.lan.auto_close_tip', { seconds: autoCloseCountdown })} -
- ) - }, [transferPhase, autoCloseCountdown, t]) - - // 错误显示组件 - const ErrorDisplay = useCallback(() => { - if (!error || transferPhase !== 'error') return null - - return ( -
- ❌ {error} -
- ) - }, [error, transferPhase]) - - return ( - - - - - - - - - - - - -
- - -
-
- - - {selectedFolderPath || t('settings.data.export_to_phone.lan.noZipSelected')} - - - - - -
- ) -} - -const TopViewKey = 'ExportToPhoneLanPopup' - -export default class ExportToPhoneLanPopup { - static topviewId = 0 - static hide() { - TopView.hide(TopViewKey) - } - static show() { - return new Promise((resolve) => { - TopView.show( - { - resolve(v) - TopView.hide(TopViewKey) - }} - />, - TopViewKey - ) - }) - } -} diff --git a/src/renderer/src/components/Popups/LanTransferPopup/LanDeviceCard.tsx b/src/renderer/src/components/Popups/LanTransferPopup/LanDeviceCard.tsx new file mode 100644 index 000000000..db16112e0 --- /dev/null +++ b/src/renderer/src/components/Popups/LanTransferPopup/LanDeviceCard.tsx @@ -0,0 +1,97 @@ +import { cn } from '@renderer/utils' +import type { FC, KeyboardEventHandler } from 'react' +import { useTranslation } from 'react-i18next' + +import { ProgressIndicator } from './ProgressIndicator' +import type { LanDeviceCardProps } from './types' + +export const LanDeviceCard: FC = ({ + service, + transferState, + isConnected, + handshakeInProgress, + isDisabled, + onSendFile +}) => { + const { t } = useTranslation() + + // Device info + const deviceName = service.txt?.modelName || t('common.unknown') + const platform = service.txt?.platform + const appVersion = service.txt?.appVersion + const platformInfo = [platform, appVersion].filter(Boolean).join(' ') + const displayTitle = platformInfo ? `${deviceName} (${platformInfo})` : deviceName + + // Address info + const primaryAddress = service.addresses?.[0] + const addressesWithPort = primaryAddress ? (service.port ? `${primaryAddress}:${service.port}` : primaryAddress) : '' + + // Progress visibility + const shouldShowProgress = + transferState && ['selecting', 'transferring', 'completed', 'failed'].includes(transferState.status) + + // Status text + const statusText = handshakeInProgress + ? t('settings.data.export_to_phone.lan.handshake.in_progress') + : isConnected + ? t('settings.data.export_to_phone.lan.connected') + : t('settings.data.export_to_phone.lan.send_file') + + // Event handlers + const handleClick = () => { + if (isDisabled) return + onSendFile(service.id) + } + + const handleKeyDown: KeyboardEventHandler = (event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + handleClick() + } + } + + return ( +
+ {/* Header */} +
+
+
{displayTitle}
+ {statusText} +
+
+ + {/* Meta Row - IP Address */} +
+ + {t('settings.data.export_to_phone.lan.ip_addresses')} + + {addressesWithPort || t('common.unknown')} +
+ + {/* Footer with Progress */} +
+ {shouldShowProgress && transferState && ( + + )} +
+
+ ) +} diff --git a/src/renderer/src/components/Popups/LanTransferPopup/ProgressIndicator.tsx b/src/renderer/src/components/Popups/LanTransferPopup/ProgressIndicator.tsx new file mode 100644 index 000000000..b9707b448 --- /dev/null +++ b/src/renderer/src/components/Popups/LanTransferPopup/ProgressIndicator.tsx @@ -0,0 +1,55 @@ +import { cn } from '@renderer/utils' +import type { FC } from 'react' +import { useTranslation } from 'react-i18next' + +import type { ProgressIndicatorProps } from './types' + +export const ProgressIndicator: FC = ({ transferState, handshakeInProgress }) => { + const { t } = useTranslation() + + const progressPercent = Math.min(100, Math.max(0, transferState.progress ?? 0)) + + const progressLabel = (() => { + if (transferState.status === 'failed') { + return transferState.error || t('common.unknown_error') + } + if (transferState.status === 'selecting') { + return handshakeInProgress + ? t('settings.data.export_to_phone.lan.handshake.in_progress') + : t('settings.data.export_to_phone.lan.status.preparing') + } + return `${Math.round(progressPercent)}%` + })() + + const isFailed = transferState.status === 'failed' + const isCompleted = transferState.status === 'completed' + + return ( +
+ {/* Label Row */} +
+ {transferState.fileName} + {progressLabel} +
+ + {/* Progress Track */} +
+
+
+
+ ) +} diff --git a/src/renderer/src/components/Popups/LanTransferPopup/hook.ts b/src/renderer/src/components/Popups/LanTransferPopup/hook.ts new file mode 100644 index 000000000..6d2ea7752 --- /dev/null +++ b/src/renderer/src/components/Popups/LanTransferPopup/hook.ts @@ -0,0 +1,397 @@ +import { loggerService } from '@logger' +import { getBackupData } from '@renderer/services/BackupService' +import type { LocalTransferPeer } from '@shared/config/types' +import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react' +import { useTranslation } from 'react-i18next' + +import type { LanPeerTransferState, LanTransferAction, LanTransferReducerState } from './types' + +const logger = loggerService.withContext('useLanTransfer') + +// ========================================== +// Initial State +// ========================================== + +export const initialState: LanTransferReducerState = { + open: true, + lanState: null, + lanHandshakePeerId: null, + lastHandshakeResult: null, + fileTransferState: {}, + tempBackupPath: null +} + +// ========================================== +// Reducer +// ========================================== + +export function lanTransferReducer(state: LanTransferReducerState, action: LanTransferAction): LanTransferReducerState { + switch (action.type) { + case 'SET_OPEN': + return { ...state, open: action.payload } + + case 'SET_LAN_STATE': + return { ...state, lanState: action.payload } + + case 'SET_HANDSHAKE_PEER_ID': + return { ...state, lanHandshakePeerId: action.payload } + + case 'SET_HANDSHAKE_RESULT': + return { ...state, lastHandshakeResult: action.payload } + + case 'SET_TEMP_BACKUP_PATH': + return { ...state, tempBackupPath: action.payload } + + case 'UPDATE_TRANSFER_STATE': { + const { peerId, state: transferState } = action.payload + return { + ...state, + fileTransferState: { + ...state.fileTransferState, + [peerId]: { + ...(state.fileTransferState[peerId] ?? { progress: 0, status: 'idle' as const }), + ...transferState + } + } + } + } + + case 'SET_TRANSFER_STATE': { + const { peerId, state: transferState } = action.payload + return { + ...state, + fileTransferState: { + ...state.fileTransferState, + [peerId]: transferState + } + } + } + + case 'CLEANUP_STALE_PEERS': { + const activeIds = action.payload + const newFileTransferState: Record = {} + for (const id of Object.keys(state.fileTransferState)) { + if (activeIds.has(id)) { + newFileTransferState[id] = state.fileTransferState[id] + } + } + return { + ...state, + fileTransferState: newFileTransferState, + lastHandshakeResult: + state.lastHandshakeResult && activeIds.has(state.lastHandshakeResult.peerId) + ? state.lastHandshakeResult + : null, + lanHandshakePeerId: + state.lanHandshakePeerId && activeIds.has(state.lanHandshakePeerId) ? state.lanHandshakePeerId : null + } + } + + case 'RESET_CONNECTION_STATE': + return { + ...state, + fileTransferState: {}, + lastHandshakeResult: null, + lanHandshakePeerId: null, + tempBackupPath: null + } + + default: + return state + } +} + +// ========================================== +// Hook Return Type +// ========================================== + +export interface UseLanTransferReturn { + // State + state: LanTransferReducerState + + // Derived values + lanDevices: LocalTransferPeer[] + isAnyTransferring: boolean + lastError: string | undefined + + // Actions + handleSendFile: (peerId: string) => Promise + handleModalCancel: () => void + getTransferState: (peerId: string) => LanPeerTransferState | undefined + isConnected: (peerId: string) => boolean + isHandshakeInProgress: (peerId: string) => boolean + + // Dispatch (for advanced use) + dispatch: React.Dispatch +} + +// ========================================== +// Hook +// ========================================== + +export function useLanTransfer(): UseLanTransferReturn { + const { t } = useTranslation() + const [state, dispatch] = useReducer(lanTransferReducer, initialState) + const isSendingRef = useRef(false) + + // ========================================== + // Derived Values + // ========================================== + + const lanDevices = useMemo(() => state.lanState?.services ?? [], [state.lanState]) + + const isAnyTransferring = useMemo( + () => Object.values(state.fileTransferState).some((s) => s.status === 'transferring' || s.status === 'selecting'), + [state.fileTransferState] + ) + + const lastError = state.lanState?.lastError + + // ========================================== + // LAN State Sync + // ========================================== + + const syncLanState = useCallback(async () => { + if (!window.api?.localTransfer) { + logger.warn('Local transfer bridge is unavailable') + return + } + try { + const nextState = await window.api.localTransfer.getState() + dispatch({ type: 'SET_LAN_STATE', payload: nextState }) + } catch (error) { + logger.error('Failed to sync LAN state', error as Error) + } + }, []) + + // ========================================== + // Send File Handler + // ========================================== + + const handleSendFile = useCallback( + async (peerId: string) => { + if (!window.api?.localTransfer || isSendingRef.current) { + return + } + isSendingRef.current = true + + dispatch({ + type: 'SET_TRANSFER_STATE', + payload: { peerId, state: { progress: 0, status: 'selecting' } } + }) + + let backupPath: string | null = null + + try { + // Step 0: Ensure handshake (connect if needed) + if (!state.lastHandshakeResult?.ack.accepted || state.lastHandshakeResult.peerId !== peerId) { + dispatch({ type: 'SET_HANDSHAKE_PEER_ID', payload: peerId }) + try { + const ack = await window.api.localTransfer.connect({ peerId }) + dispatch({ + type: 'SET_HANDSHAKE_RESULT', + payload: { peerId, ack, timestamp: Date.now() } + }) + if (!ack.accepted) { + throw new Error(ack.message || t('settings.data.export_to_phone.lan.connection_failed')) + } + } finally { + dispatch({ type: 'SET_HANDSHAKE_PEER_ID', payload: null }) + } + } + + // Step 1: Create temporary backup + logger.info('Creating temporary backup for LAN transfer...') + const backupData = await getBackupData() + backupPath = await window.api.backup.createLanTransferBackup(backupData) + dispatch({ type: 'SET_TEMP_BACKUP_PATH', payload: backupPath }) + + // Extract filename from path + const fileName = backupPath.split(/[/\\]/).pop() || 'backup.zip' + + // Step 2: Set transferring state + dispatch({ + type: 'UPDATE_TRANSFER_STATE', + payload: { peerId, state: { fileName, progress: 0, status: 'transferring' } } + }) + + // Step 3: Send file + logger.info(`Sending backup file: ${backupPath}`) + const result = await window.api.localTransfer.sendFile(backupPath) + + if (result.success) { + dispatch({ + type: 'UPDATE_TRANSFER_STATE', + payload: { peerId, state: { progress: 100, status: 'completed' } } + }) + } else { + dispatch({ + type: 'UPDATE_TRANSFER_STATE', + payload: { peerId, state: { status: 'failed', error: result.error } } + }) + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + dispatch({ + type: 'UPDATE_TRANSFER_STATE', + payload: { peerId, state: { status: 'failed', error: message } } + }) + logger.error('Failed to send file', error as Error) + } finally { + // Step 4: Clean up temp file + if (backupPath) { + try { + await window.api.backup.deleteTempBackup(backupPath) + logger.info('Cleaned up temporary backup file') + } catch (cleanupError) { + logger.warn('Failed to clean up temp backup', cleanupError as Error) + } + dispatch({ type: 'SET_TEMP_BACKUP_PATH', payload: null }) + } + isSendingRef.current = false + } + }, + [state.lastHandshakeResult, t] + ) + + // ========================================== + // Teardown + // ========================================== + + // Use ref to track temp backup path for cleanup without causing effect re-runs + const tempBackupPathRef = useRef(null) + tempBackupPathRef.current = state.tempBackupPath + + const teardownLan = useCallback(async () => { + if (!window.api?.localTransfer) { + return + } + try { + await window.api.localTransfer.cancelTransfer?.() + } catch (error) { + logger.warn('Failed to cancel LAN transfer on close', error as Error) + } + try { + await window.api.localTransfer.disconnect?.() + } catch (error) { + logger.warn('Failed to disconnect LAN on close', error as Error) + } + // Clean up temp backup if exists (use ref to get current value) + if (tempBackupPathRef.current) { + try { + await window.api.backup.deleteTempBackup(tempBackupPathRef.current) + } catch (error) { + logger.warn('Failed to cleanup temp backup on close', error as Error) + } + } + dispatch({ type: 'RESET_CONNECTION_STATE' }) + }, []) // No dependencies - uses ref for current value + + const handleModalCancel = useCallback(() => { + void teardownLan() + dispatch({ type: 'SET_OPEN', payload: false }) + }, [teardownLan]) + + // ========================================== + // Effects + // ========================================== + + // Initial sync and service listener + useEffect(() => { + if (!window.api?.localTransfer) { + return + } + syncLanState() + const removeListener = window.api.localTransfer.onServicesUpdated((lanState) => { + dispatch({ type: 'SET_LAN_STATE', payload: lanState }) + }) + return () => { + removeListener?.() + } + }, [syncLanState]) + + // Client events listener (progress, completion) + useEffect(() => { + if (!window.api?.localTransfer) { + return + } + const removeListener = window.api.localTransfer.onClientEvent((event) => { + const key = event.peerId ?? 'global' + + if (event.type === 'file_transfer_progress') { + dispatch({ + type: 'UPDATE_TRANSFER_STATE', + payload: { + peerId: key, + state: { + transferId: event.transferId, + fileName: event.fileName, + progress: event.progress, + speed: event.speed, + status: 'transferring' + } + } + }) + } else if (event.type === 'file_transfer_complete') { + dispatch({ + type: 'UPDATE_TRANSFER_STATE', + payload: { + peerId: key, + state: { + progress: event.success ? 100 : undefined, + status: event.success ? 'completed' : 'failed', + error: event.error + } + } + }) + } + }) + return () => { + removeListener?.() + } + }, []) + + // Cleanup stale peers when services change + useEffect(() => { + const activeIds = new Set(lanDevices.map((s) => s.id)) + dispatch({ type: 'CLEANUP_STALE_PEERS', payload: activeIds }) + }, [lanDevices]) + + // Cleanup on unmount only (teardownLan is stable with no deps) + useEffect(() => { + return () => { + void teardownLan() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // ========================================== + // Helper Functions + // ========================================== + + const getTransferState = useCallback((peerId: string) => state.fileTransferState[peerId], [state.fileTransferState]) + + const isConnected = useCallback( + (peerId: string) => + state.lastHandshakeResult?.peerId === peerId && state.lastHandshakeResult?.ack.accepted === true, + [state.lastHandshakeResult] + ) + + const isHandshakeInProgress = useCallback( + (peerId: string) => state.lanHandshakePeerId === peerId, + [state.lanHandshakePeerId] + ) + + return { + state, + lanDevices, + isAnyTransferring, + lastError, + handleSendFile, + handleModalCancel, + getTransferState, + isConnected, + isHandshakeInProgress, + dispatch + } +} diff --git a/src/renderer/src/components/Popups/LanTransferPopup/index.tsx b/src/renderer/src/components/Popups/LanTransferPopup/index.tsx new file mode 100644 index 000000000..66455f12a --- /dev/null +++ b/src/renderer/src/components/Popups/LanTransferPopup/index.tsx @@ -0,0 +1,37 @@ +import { TopView } from '@renderer/components/TopView' + +import { getHideCallback, PopupContainer } from './popup' +import type { PopupResolveData } from './types' + +// Re-export types for external use +export type { LanPeerTransferState } from './types' + +const TopViewKey = 'LanTransferPopup' + +export default class LanTransferPopup { + static topviewId = 0 + + static hide() { + // Try to use the registered callback for proper cleanup, fallback to TopView.hide + const callback = getHideCallback() + if (callback) { + callback() + } else { + TopView.hide(TopViewKey) + } + } + + static show() { + return new Promise((resolve) => { + TopView.show( + { + resolve(v) + TopView.hide(TopViewKey) + }} + />, + TopViewKey + ) + }) + } +} diff --git a/src/renderer/src/components/Popups/LanTransferPopup/popup.tsx b/src/renderer/src/components/Popups/LanTransferPopup/popup.tsx new file mode 100644 index 000000000..34c53a6ad --- /dev/null +++ b/src/renderer/src/components/Popups/LanTransferPopup/popup.tsx @@ -0,0 +1,88 @@ +import { Modal } from 'antd' +import { TriangleAlert } from 'lucide-react' +import type { FC } from 'react' +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' + +import { useLanTransfer } from './hook' +import { LanDeviceCard } from './LanDeviceCard' +import type { PopupContainerProps } from './types' + +// Module-level callback for external hide access +let hideCallback: (() => void) | null = null +export const setHideCallback = (cb: () => void) => { + hideCallback = cb +} +export const getHideCallback = () => hideCallback + +export const PopupContainer: FC = ({ resolve }) => { + const { t } = useTranslation() + + const { + state, + lanDevices, + isAnyTransferring, + lastError, + handleSendFile, + handleModalCancel, + getTransferState, + isConnected, + isHandshakeInProgress + } = useLanTransfer() + + const contentTitle = useMemo(() => t('settings.data.export_to_phone.lan.title'), [t]) + + const onClose = () => resolve({}) + + // Register hide callback for external access + setHideCallback(handleModalCancel) + + return ( + +
+ {/* Error Display */} + {lastError &&
{lastError}
} + + {/* Device List */} +
+ {lanDevices.length === 0 ? ( + // Warning when no devices +
+ + + {t('settings.data.export_to_phone.lan.no_connection_warning')} + +
+ ) : ( + // Device cards + lanDevices.map((service) => { + const transferState = getTransferState(service.id) + const connected = isConnected(service.id) + const handshakeInProgress = isHandshakeInProgress(service.id) + const isCardDisabled = isAnyTransferring || handshakeInProgress + + return ( + + ) + }) + )} +
+
+
+ ) +} diff --git a/src/renderer/src/components/Popups/LanTransferPopup/types.ts b/src/renderer/src/components/Popups/LanTransferPopup/types.ts new file mode 100644 index 000000000..644541bc2 --- /dev/null +++ b/src/renderer/src/components/Popups/LanTransferPopup/types.ts @@ -0,0 +1,84 @@ +import type { LanHandshakeAckMessage, LocalTransferPeer, LocalTransferState } from '@shared/config/types' + +// ========================================== +// Transfer Status +// ========================================== + +export type TransferStatus = 'idle' | 'selecting' | 'transferring' | 'completed' | 'failed' + +// ========================================== +// Per-Peer Transfer State +// ========================================== + +export interface LanPeerTransferState { + transferId?: string + fileName?: string + progress: number + speed?: number + status: TransferStatus + error?: string +} + +// ========================================== +// Handshake Result +// ========================================== + +export type HandshakeResult = { + peerId: string + ack: LanHandshakeAckMessage + timestamp: number +} | null + +// ========================================== +// Reducer State +// ========================================== + +export interface LanTransferReducerState { + open: boolean + lanState: LocalTransferState | null + lanHandshakePeerId: string | null + lastHandshakeResult: HandshakeResult + fileTransferState: Record + tempBackupPath: string | null +} + +// ========================================== +// Reducer Actions +// ========================================== + +export type LanTransferAction = + | { type: 'SET_OPEN'; payload: boolean } + | { type: 'SET_LAN_STATE'; payload: LocalTransferState | null } + | { type: 'SET_HANDSHAKE_PEER_ID'; payload: string | null } + | { type: 'SET_HANDSHAKE_RESULT'; payload: HandshakeResult } + | { type: 'SET_TEMP_BACKUP_PATH'; payload: string | null } + | { type: 'UPDATE_TRANSFER_STATE'; payload: { peerId: string; state: Partial } } + | { type: 'SET_TRANSFER_STATE'; payload: { peerId: string; state: LanPeerTransferState } } + | { type: 'CLEANUP_STALE_PEERS'; payload: Set } + | { type: 'RESET_CONNECTION_STATE' } + +// ========================================== +// Component Props +// ========================================== + +export interface LanDeviceCardProps { + service: LocalTransferPeer + transferState?: LanPeerTransferState + isConnected: boolean + handshakeInProgress: boolean + isDisabled: boolean + onSendFile: (peerId: string) => void +} + +export interface ProgressIndicatorProps { + transferState: LanPeerTransferState + handshakeInProgress: boolean +} + +export interface PopupResolveData { + // Empty for now, can be extended +} + +export interface PopupContainerProps { + resolve: (data: PopupResolveData) => void +} diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 4ebc57cb9..e6c335646 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -3166,18 +3166,46 @@ "connected": "Connected", "connection_failed": "Connection failed", "content": "Please ensure your computer and phone are on the same network for LAN transfer. Open the Cherry Studio App to scan this QR code.", + "device_list_title": "Local network devices", + "discovered_devices": "Discovered devices", "error": { + "file_too_large": "File too large, maximum 500MB supported", "init_failed": "Initialization failed", + "invalid_file_type": "Only ZIP files are supported", "no_file": "No file selected", "no_ip": "Unable to get IP address", + "not_connected": "Please complete handshake first", "send_failed": "Failed to send file" }, + "file_transfer": { + "cancelled": "Transfer cancelled", + "failed": "File transfer failed: {{message}}", + "progress": "Sending... {{progress}}%", + "success": "File sent successfully" + }, "force_close": "Force Close", "generating_qr": "Generating QR code...", + "handshake": { + "button": "Handshake", + "failed": "Handshake failed: {{message}}", + "in_progress": "Handshaking...", + "success": "Handshake completed with {{device}}", + "test_message_received": "Received pong from {{device}}", + "test_message_sent": "Sent hello world test payload" + }, + "idle_hint": "Scan paused. Start scanning to find Cherry Studio peers on your LAN.", + "ip_addresses": "IP addresses", + "last_seen": "Last seen at {{time}}", + "metadata": "Metadata", "noZipSelected": "No compressed file selected", + "no_connection_warning": "Please open LAN Transfer on Cherry Studio mobile", + "no_devices": "No LAN peers found yet", + "scan_devices": "Scan devices", "scan_qr": "Please scan QR code with your phone", + "scanning_hint": "Scanning your local network for Cherry Studio peers...", "selectZip": "Select a compressed file", "sendZip": "Begin data recovery", + "send_file": "Send File", "status": { "completed": "Transfer completed", "connected": "Connected", @@ -3189,6 +3217,9 @@ "sending": "Transferring {{progress}}%", "waiting_qr_scan": "Please scan QR code to connect" }, + "status_badge_idle": "Idle", + "status_badge_scanning": "Scanning", + "stop_scan": "Stop scan", "title": "LAN transmission", "transfer_progress": "Transfer progress" }, diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 8829bfe08..40ff86f43 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -3166,18 +3166,46 @@ "connected": "连接成功", "connection_failed": "连接失败", "content": "请确保电脑和手机处于同一网络以使用局域网传输。请打开 Cherry Studio App 扫描此二维码。", + "device_list_title": "局域网设备列表", + "discovered_devices": "已发现的设备", "error": { + "file_too_large": "文件过大,最大支持 500MB", "init_failed": "初始化失败", + "invalid_file_type": "仅支持 ZIP 文件", "no_file": "未选择文件", "no_ip": "无法获取 IP 地址", + "not_connected": "请先完成握手连接", "send_failed": "发送文件失败" }, + "file_transfer": { + "cancelled": "传输已取消", + "failed": "文件发送失败: {{message}}", + "progress": "发送中... {{progress}}%", + "success": "文件发送成功" + }, "force_close": "强制关闭", "generating_qr": "正在生成二维码...", + "handshake": { + "button": "握手测试", + "failed": "握手失败:{{message}}", + "in_progress": "正在握手...", + "success": "已与 {{device}} 建立握手", + "test_message_received": "已收到 {{device}} 的 pong 响应", + "test_message_sent": "已发送 hello world 测试数据" + }, + "idle_hint": "扫描已暂停。开始扫描以发现局域网中的 Cherry Studio 设备。", + "ip_addresses": "IP 地址", + "last_seen": "最后活动:{{time}}", + "metadata": "元数据", "noZipSelected": "未选择压缩文件", + "no_connection_warning": "请在 Cherry Studio 移动端打开局域网传输", + "no_devices": "尚未发现局域网设备", + "scan_devices": "扫描设备", "scan_qr": "请使用手机扫码连接", + "scanning_hint": "正在扫描局域网中的 Cherry Studio 设备...", "selectZip": "选择压缩文件", "sendZip": "开始恢复数据", + "send_file": "发送文件", "status": { "completed": "传输完成", "connected": "连接成功", @@ -3189,6 +3217,9 @@ "sending": "传输中 {{progress}}%", "waiting_qr_scan": "请扫描二维码连接" }, + "status_badge_idle": "空闲", + "status_badge_scanning": "扫描中", + "stop_scan": "停止扫描", "title": "局域网传输", "transfer_progress": "传输进度" }, diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 1a036a29e..d4dc05cd1 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -3166,18 +3166,46 @@ "connected": "已連線", "connection_failed": "連線失敗", "content": "請確保電腦和手機處於同一網路以使用區域網路傳輸。請打開 Cherry Studio App 掃描此 QR 碼。", + "device_list_title": "[to be translated]:Local network devices", + "discovered_devices": "[to be translated]:Discovered devices", "error": { + "file_too_large": "[to be translated]:File too large, maximum 500MB supported", "init_failed": "初始化失敗", + "invalid_file_type": "[to be translated]:Only ZIP files are supported", "no_file": "未選擇檔案", "no_ip": "無法取得 IP 位址", + "not_connected": "[to be translated]:Please complete handshake first", "send_failed": "無法傳送檔案" }, + "file_transfer": { + "cancelled": "[to be translated]:Transfer cancelled", + "failed": "[to be translated]:File transfer failed: {{message}}", + "progress": "[to be translated]:Sending... {{progress}}%", + "success": "[to be translated]:File sent successfully" + }, "force_close": "強制關閉", "generating_qr": "正在生成 QR 碼...", + "handshake": { + "button": "握手測試", + "failed": "握手失敗:{{message}}", + "in_progress": "握手中...", + "success": "已與 {{device}} 建立握手", + "test_message_received": "已收到 {{device}} 的 pong 回應", + "test_message_sent": "已送出 hello world 測試資料" + }, + "idle_hint": "[to be translated]:Scan paused. Start scanning to find Cherry Studio peers on your LAN.", + "ip_addresses": "[to be translated]:IP addresses", + "last_seen": "[to be translated]:Last seen at {{time}}", + "metadata": "[to be translated]:Metadata", "noZipSelected": "未選取壓縮檔案", + "no_connection_warning": "[to be translated]:Please open LAN Transfer on Cherry Studio mobile", + "no_devices": "[to be translated]:No LAN peers found yet", + "scan_devices": "[to be translated]:Scan devices", "scan_qr": "請使用手機掃描QR碼", + "scanning_hint": "[to be translated]:Scanning your local network for Cherry Studio peers...", "selectZip": "選擇壓縮檔案", "sendZip": "開始恢復資料", + "send_file": "[to be translated]:Send File", "status": { "completed": "轉帳完成", "connected": "已連線", @@ -3189,6 +3217,9 @@ "sending": "傳輸中 {{progress}}%", "waiting_qr_scan": "請掃描QR碼以連接" }, + "status_badge_idle": "[to be translated]:Idle", + "status_badge_scanning": "[to be translated]:Scanning", + "stop_scan": "[to be translated]:Stop scan", "title": "區域網路傳輸", "transfer_progress": "傳輸進度" }, diff --git a/src/renderer/src/i18n/translate/de-de.json b/src/renderer/src/i18n/translate/de-de.json index 1300fbf6c..0618c27f9 100644 --- a/src/renderer/src/i18n/translate/de-de.json +++ b/src/renderer/src/i18n/translate/de-de.json @@ -3166,18 +3166,46 @@ "connected": "Verbunden", "connection_failed": "Verbindung fehlgeschlagen", "content": "Bitte stelle sicher, dass sich dein Computer und dein Telefon im selben Netzwerk befinden, um eine LAN-Übertragung durchzuführen. Öffne die Cherry Studio App, um diesen QR-Code zu scannen.", + "device_list_title": "[to be translated]:Local network devices", + "discovered_devices": "[to be translated]:Discovered devices", "error": { + "file_too_large": "[to be translated]:File too large, maximum 500MB supported", "init_failed": "Initialisierung fehlgeschlagen", + "invalid_file_type": "[to be translated]:Only ZIP files are supported", "no_file": "Keine Datei ausgewählt", "no_ip": "IP-Adresse kann nicht abgerufen werden", + "not_connected": "[to be translated]:Please complete handshake first", "send_failed": "Fehler beim Senden der Datei" }, + "file_transfer": { + "cancelled": "[to be translated]:Transfer cancelled", + "failed": "[to be translated]:File transfer failed: {{message}}", + "progress": "[to be translated]:Sending... {{progress}}%", + "success": "[to be translated]:File sent successfully" + }, "force_close": "Erzwungenes Schließen", "generating_qr": "QR-Code wird generiert...", + "handshake": { + "button": "[to be translated]:Handshake", + "failed": "[to be translated]:Handshake failed: {{message}}", + "in_progress": "[to be translated]:Handshaking...", + "success": "[to be translated]:Handshake completed with {{device}}", + "test_message_received": "[to be translated]:Received pong from {{device}}", + "test_message_sent": "[to be translated]:Sent hello world test payload" + }, + "idle_hint": "[to be translated]:Scan paused. Start scanning to find Cherry Studio peers on your LAN.", + "ip_addresses": "[to be translated]:IP addresses", + "last_seen": "[to be translated]:Last seen at {{time}}", + "metadata": "[to be translated]:Metadata", "noZipSelected": "Keine komprimierte Datei ausgewählt", + "no_connection_warning": "[to be translated]:Please open LAN Transfer on Cherry Studio mobile", + "no_devices": "[to be translated]:No LAN peers found yet", + "scan_devices": "[to be translated]:Scan devices", "scan_qr": "Bitte scannen Sie den QR-Code mit Ihrem Telefon.", + "scanning_hint": "[to be translated]:Scanning your local network for Cherry Studio peers...", "selectZip": "Wählen Sie eine komprimierte Datei", "sendZip": "Datenwiederherstellung beginnen", + "send_file": "[to be translated]:Send File", "status": { "completed": "Übertragung abgeschlossen", "connected": "Verbunden", @@ -3189,6 +3217,9 @@ "sending": "Übertrage {{progress}}%", "waiting_qr_scan": "Bitte QR-Code scannen, um zu verbinden" }, + "status_badge_idle": "[to be translated]:Idle", + "status_badge_scanning": "[to be translated]:Scanning", + "stop_scan": "[to be translated]:Stop scan", "title": "LAN-Übertragung", "transfer_progress": "Übertragungsfortschritt" }, diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index 535a36489..89506c9e8 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -3166,18 +3166,46 @@ "connected": "Συνδεδεμένος", "connection_failed": "Η σύνδεση απέτυχε", "content": "Βεβαιωθείτε ότι ο υπολογιστής και το κινητό βρίσκονται στο ίδιο δίκτυο για να χρησιμοποιήσετε τη μεταφορά LAN. Ανοίξτε την εφαρμογή Cherry Studio και σαρώστε αυτόν τον κωδικό QR.", + "device_list_title": "[to be translated]:Local network devices", + "discovered_devices": "[to be translated]:Discovered devices", "error": { + "file_too_large": "[to be translated]:File too large, maximum 500MB supported", "init_failed": "Η αρχικοποίηση απέτυχε", + "invalid_file_type": "[to be translated]:Only ZIP files are supported", "no_file": "Κανένα αρχείο δεν επιλέχθηκε", "no_ip": "Αδυναμία λήψης διεύθυνσης IP", + "not_connected": "[to be translated]:Please complete handshake first", "send_failed": "Αποτυχία αποστολής αρχείου" }, + "file_transfer": { + "cancelled": "[to be translated]:Transfer cancelled", + "failed": "[to be translated]:File transfer failed: {{message}}", + "progress": "[to be translated]:Sending... {{progress}}%", + "success": "[to be translated]:File sent successfully" + }, "force_close": "Κλείσιμο με βία", "generating_qr": "Δημιουργία κώδικα QR...", + "handshake": { + "button": "[to be translated]:Handshake", + "failed": "[to be translated]:Handshake failed: {{message}}", + "in_progress": "[to be translated]:Handshaking...", + "success": "[to be translated]:Handshake completed with {{device}}", + "test_message_received": "[to be translated]:Received pong from {{device}}", + "test_message_sent": "[to be translated]:Sent hello world test payload" + }, + "idle_hint": "[to be translated]:Scan paused. Start scanning to find Cherry Studio peers on your LAN.", + "ip_addresses": "[to be translated]:IP addresses", + "last_seen": "[to be translated]:Last seen at {{time}}", + "metadata": "[to be translated]:Metadata", "noZipSelected": "Δεν επιλέχθηκε συμπιεσμένο αρχείο", + "no_connection_warning": "[to be translated]:Please open LAN Transfer on Cherry Studio mobile", + "no_devices": "[to be translated]:No LAN peers found yet", + "scan_devices": "[to be translated]:Scan devices", "scan_qr": "Παρακαλώ σαρώστε τον κωδικό QR με το τηλέφωνό σας", + "scanning_hint": "[to be translated]:Scanning your local network for Cherry Studio peers...", "selectZip": "Επιλέξτε συμπιεσμένο αρχείο", "sendZip": "Έναρξη ανάκτησης δεδομένων", + "send_file": "[to be translated]:Send File", "status": { "completed": "Η μεταφορά ολοκληρώθηκε", "connected": "Συνδεδεμένος", @@ -3189,6 +3217,9 @@ "sending": "Μεταφορά {{progress}}%", "waiting_qr_scan": "Παρακαλώ σαρώστε τον κωδικό QR για σύνδεση" }, + "status_badge_idle": "[to be translated]:Idle", + "status_badge_scanning": "[to be translated]:Scanning", + "stop_scan": "[to be translated]:Stop scan", "title": "Μεταφορά τοπικού δικτύου", "transfer_progress": "Πρόοδος μεταφοράς" }, diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index 43d5919f0..bb4c4411e 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -3166,18 +3166,46 @@ "connected": "Conectado", "connection_failed": "Conexión fallida", "content": "Asegúrate de que el ordenador y el móvil estén en la misma red para usar la transferencia por LAN. Abre la aplicación Cherry Studio y escanea este código QR.", + "device_list_title": "[to be translated]:Local network devices", + "discovered_devices": "[to be translated]:Discovered devices", "error": { + "file_too_large": "[to be translated]:File too large, maximum 500MB supported", "init_failed": "Falló la inicialización", + "invalid_file_type": "[to be translated]:Only ZIP files are supported", "no_file": "Ningún archivo seleccionado", "no_ip": "No se puede obtener la dirección IP", + "not_connected": "[to be translated]:Please complete handshake first", "send_failed": "Error al enviar el archivo" }, + "file_transfer": { + "cancelled": "[to be translated]:Transfer cancelled", + "failed": "[to be translated]:File transfer failed: {{message}}", + "progress": "[to be translated]:Sending... {{progress}}%", + "success": "[to be translated]:File sent successfully" + }, "force_close": "Cerrar forzosamente", "generating_qr": "Generando código QR...", + "handshake": { + "button": "[to be translated]:Handshake", + "failed": "[to be translated]:Handshake failed: {{message}}", + "in_progress": "[to be translated]:Handshaking...", + "success": "[to be translated]:Handshake completed with {{device}}", + "test_message_received": "[to be translated]:Received pong from {{device}}", + "test_message_sent": "[to be translated]:Sent hello world test payload" + }, + "idle_hint": "[to be translated]:Scan paused. Start scanning to find Cherry Studio peers on your LAN.", + "ip_addresses": "[to be translated]:IP addresses", + "last_seen": "[to be translated]:Last seen at {{time}}", + "metadata": "[to be translated]:Metadata", "noZipSelected": "No se ha seleccionado ningún archivo comprimido", + "no_connection_warning": "[to be translated]:Please open LAN Transfer on Cherry Studio mobile", + "no_devices": "[to be translated]:No LAN peers found yet", + "scan_devices": "[to be translated]:Scan devices", "scan_qr": "Por favor, escanea el código QR con tu teléfono", + "scanning_hint": "[to be translated]:Scanning your local network for Cherry Studio peers...", "selectZip": "Seleccionar archivo comprimido", "sendZip": "Comenzar la recuperación de datos", + "send_file": "[to be translated]:Send File", "status": { "completed": "Transferencia completada", "connected": "Conectado", @@ -3189,6 +3217,9 @@ "sending": "Transfiriendo {{progress}}%", "waiting_qr_scan": "Por favor, escanea el código QR para conectarte" }, + "status_badge_idle": "[to be translated]:Idle", + "status_badge_scanning": "[to be translated]:Scanning", + "stop_scan": "[to be translated]:Stop scan", "title": "Transferencia de red local", "transfer_progress": "Progreso de transferencia" }, diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index ef7db8b3b..f6d7ac081 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -3166,18 +3166,46 @@ "connected": "Connecté", "connection_failed": "Échec de la connexion", "content": "Assurez-vous que l'ordinateur et le téléphone sont connectés au même réseau pour utiliser le transfert en réseau local. Ouvrez l'application Cherry Studio et scannez ce code QR.", + "device_list_title": "[to be translated]:Local network devices", + "discovered_devices": "[to be translated]:Discovered devices", "error": { + "file_too_large": "[to be translated]:File too large, maximum 500MB supported", "init_failed": "Échec de l'initialisation", + "invalid_file_type": "[to be translated]:Only ZIP files are supported", "no_file": "Aucun fichier sélectionné", "no_ip": "Impossible d'obtenir l'adresse IP", + "not_connected": "[to be translated]:Please complete handshake first", "send_failed": "Échec de l'envoi du fichier" }, + "file_transfer": { + "cancelled": "[to be translated]:Transfer cancelled", + "failed": "[to be translated]:File transfer failed: {{message}}", + "progress": "[to be translated]:Sending... {{progress}}%", + "success": "[to be translated]:File sent successfully" + }, "force_close": "Fermer de force", "generating_qr": "Génération du code QR...", + "handshake": { + "button": "[to be translated]:Handshake", + "failed": "[to be translated]:Handshake failed: {{message}}", + "in_progress": "[to be translated]:Handshaking...", + "success": "[to be translated]:Handshake completed with {{device}}", + "test_message_received": "[to be translated]:Received pong from {{device}}", + "test_message_sent": "[to be translated]:Sent hello world test payload" + }, + "idle_hint": "[to be translated]:Scan paused. Start scanning to find Cherry Studio peers on your LAN.", + "ip_addresses": "[to be translated]:IP addresses", + "last_seen": "[to be translated]:Last seen at {{time}}", + "metadata": "[to be translated]:Metadata", "noZipSelected": "Aucun fichier compressé sélectionné", + "no_connection_warning": "[to be translated]:Please open LAN Transfer on Cherry Studio mobile", + "no_devices": "[to be translated]:No LAN peers found yet", + "scan_devices": "[to be translated]:Scan devices", "scan_qr": "Veuillez scanner le code QR avec votre téléphone", + "scanning_hint": "[to be translated]:Scanning your local network for Cherry Studio peers...", "selectZip": "Sélectionner le fichier compressé", "sendZip": "Commencer la restauration des données", + "send_file": "[to be translated]:Send File", "status": { "completed": "Transfert terminé", "connected": "Connecté", @@ -3189,6 +3217,9 @@ "sending": "Transfert {{progress}} %", "waiting_qr_scan": "Veuillez scanner le code QR pour vous connecter" }, + "status_badge_idle": "[to be translated]:Idle", + "status_badge_scanning": "[to be translated]:Scanning", + "stop_scan": "[to be translated]:Stop scan", "title": "Transmission en réseau local", "transfer_progress": "Progression du transfert" }, diff --git a/src/renderer/src/i18n/translate/ja-jp.json b/src/renderer/src/i18n/translate/ja-jp.json index 42c50c882..2de5e4239 100644 --- a/src/renderer/src/i18n/translate/ja-jp.json +++ b/src/renderer/src/i18n/translate/ja-jp.json @@ -3166,18 +3166,46 @@ "connected": "接続済み", "connection_failed": "接続に失敗しました", "content": "コンピューターとスマートフォンが同じネットワークに接続されていることを確認し、ローカルエリアネットワーク転送を使用してください。Cherry Studioアプリを開き、このQRコードをスキャンしてください。", + "device_list_title": "[to be translated]:Local network devices", + "discovered_devices": "[to be translated]:Discovered devices", "error": { + "file_too_large": "[to be translated]:File too large, maximum 500MB supported", "init_failed": "初期化に失敗しました", + "invalid_file_type": "[to be translated]:Only ZIP files are supported", "no_file": "ファイルが選択されていません", "no_ip": "IPアドレスを取得できません", + "not_connected": "[to be translated]:Please complete handshake first", "send_failed": "ファイルの送信に失敗しました" }, + "file_transfer": { + "cancelled": "[to be translated]:Transfer cancelled", + "failed": "[to be translated]:File transfer failed: {{message}}", + "progress": "[to be translated]:Sending... {{progress}}%", + "success": "[to be translated]:File sent successfully" + }, "force_close": "強制終了", "generating_qr": "QRコードを生成中...", + "handshake": { + "button": "[to be translated]:Handshake", + "failed": "[to be translated]:Handshake failed: {{message}}", + "in_progress": "[to be translated]:Handshaking...", + "success": "[to be translated]:Handshake completed with {{device}}", + "test_message_received": "[to be translated]:Received pong from {{device}}", + "test_message_sent": "[to be translated]:Sent hello world test payload" + }, + "idle_hint": "[to be translated]:Scan paused. Start scanning to find Cherry Studio peers on your LAN.", + "ip_addresses": "[to be translated]:IP addresses", + "last_seen": "[to be translated]:Last seen at {{time}}", + "metadata": "[to be translated]:Metadata", "noZipSelected": "圧縮ファイルが選択されていません", + "no_connection_warning": "[to be translated]:Please open LAN Transfer on Cherry Studio mobile", + "no_devices": "[to be translated]:No LAN peers found yet", + "scan_devices": "[to be translated]:Scan devices", "scan_qr": "携帯電話でQRコードをスキャンしてください", + "scanning_hint": "[to be translated]:Scanning your local network for Cherry Studio peers...", "selectZip": "圧縮ファイルを選択", "sendZip": "データの復元を開始します", + "send_file": "[to be translated]:Send File", "status": { "completed": "転送完了", "connected": "接続済み", @@ -3189,6 +3217,9 @@ "sending": "転送中 {{progress}}%", "waiting_qr_scan": "QRコードをスキャンして接続してください" }, + "status_badge_idle": "[to be translated]:Idle", + "status_badge_scanning": "[to be translated]:Scanning", + "stop_scan": "[to be translated]:Stop scan", "title": "LAN転送", "transfer_progress": "転送進行" }, diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index bc84fc99b..dab16e65b 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -3166,18 +3166,46 @@ "connected": "Conectado", "connection_failed": "Falha na conexão", "content": "Certifique-se de que o computador e o telefone estejam na mesma rede para usar a transferência via LAN. Abra o aplicativo Cherry Studio e escaneie este código QR.", + "device_list_title": "[to be translated]:Local network devices", + "discovered_devices": "[to be translated]:Discovered devices", "error": { + "file_too_large": "[to be translated]:File too large, maximum 500MB supported", "init_failed": "Falha na inicialização", + "invalid_file_type": "[to be translated]:Only ZIP files are supported", "no_file": "Nenhum arquivo selecionado", "no_ip": "Incapaz de obter endereço IP", + "not_connected": "[to be translated]:Please complete handshake first", "send_failed": "Falha ao enviar arquivo" }, + "file_transfer": { + "cancelled": "[to be translated]:Transfer cancelled", + "failed": "[to be translated]:File transfer failed: {{message}}", + "progress": "[to be translated]:Sending... {{progress}}%", + "success": "[to be translated]:File sent successfully" + }, "force_close": "Forçar Fechamento", "generating_qr": "Gerando código QR...", + "handshake": { + "button": "[to be translated]:Handshake", + "failed": "[to be translated]:Handshake failed: {{message}}", + "in_progress": "[to be translated]:Handshaking...", + "success": "[to be translated]:Handshake completed with {{device}}", + "test_message_received": "[to be translated]:Received pong from {{device}}", + "test_message_sent": "[to be translated]:Sent hello world test payload" + }, + "idle_hint": "[to be translated]:Scan paused. Start scanning to find Cherry Studio peers on your LAN.", + "ip_addresses": "[to be translated]:IP addresses", + "last_seen": "[to be translated]:Last seen at {{time}}", + "metadata": "[to be translated]:Metadata", "noZipSelected": "Nenhum arquivo de compressão selecionado", + "no_connection_warning": "[to be translated]:Please open LAN Transfer on Cherry Studio mobile", + "no_devices": "[to be translated]:No LAN peers found yet", + "scan_devices": "[to be translated]:Scan devices", "scan_qr": "Por favor, escaneie o código QR com o seu telefone", + "scanning_hint": "[to be translated]:Scanning your local network for Cherry Studio peers...", "selectZip": "Selecionar arquivo compactado", "sendZip": "Iniciar recuperação de dados", + "send_file": "[to be translated]:Send File", "status": { "completed": "Transferência concluída", "connected": "Conectado", @@ -3189,6 +3217,9 @@ "sending": "Transferindo {{progress}}%", "waiting_qr_scan": "Por favor, escaneie o código QR para conectar" }, + "status_badge_idle": "[to be translated]:Idle", + "status_badge_scanning": "[to be translated]:Scanning", + "stop_scan": "[to be translated]:Stop scan", "title": "transmissão de rede local", "transfer_progress": "Progresso da transferência" }, diff --git a/src/renderer/src/i18n/translate/ru-ru.json b/src/renderer/src/i18n/translate/ru-ru.json index 109bffb9b..11c64697e 100644 --- a/src/renderer/src/i18n/translate/ru-ru.json +++ b/src/renderer/src/i18n/translate/ru-ru.json @@ -3166,18 +3166,46 @@ "connected": "Подключено", "connection_failed": "Соединение не удалось", "content": "Убедитесь, что компьютер и телефон подключены к одной сети, чтобы использовать локальную передачу. Откройте приложение Cherry Studio и отсканируйте этот QR-код.", + "device_list_title": "[to be translated]:Local network devices", + "discovered_devices": "[to be translated]:Discovered devices", "error": { + "file_too_large": "[to be translated]:File too large, maximum 500MB supported", "init_failed": "Инициализация не удалась", + "invalid_file_type": "[to be translated]:Only ZIP files are supported", "no_file": "Файл не выбран", "no_ip": "Не удалось получить IP-адрес", + "not_connected": "[to be translated]:Please complete handshake first", "send_failed": "Не удалось отправить файл" }, + "file_transfer": { + "cancelled": "[to be translated]:Transfer cancelled", + "failed": "[to be translated]:File transfer failed: {{message}}", + "progress": "[to be translated]:Sending... {{progress}}%", + "success": "[to be translated]:File sent successfully" + }, "force_close": "Принудительное закрытие", "generating_qr": "Генерация QR-кода...", + "handshake": { + "button": "[to be translated]:Handshake", + "failed": "[to be translated]:Handshake failed: {{message}}", + "in_progress": "[to be translated]:Handshaking...", + "success": "[to be translated]:Handshake completed with {{device}}", + "test_message_received": "[to be translated]:Received pong from {{device}}", + "test_message_sent": "[to be translated]:Sent hello world test payload" + }, + "idle_hint": "[to be translated]:Scan paused. Start scanning to find Cherry Studio peers on your LAN.", + "ip_addresses": "[to be translated]:IP addresses", + "last_seen": "[to be translated]:Last seen at {{time}}", + "metadata": "[to be translated]:Metadata", "noZipSelected": "Архив не выбран", + "no_connection_warning": "[to be translated]:Please open LAN Transfer on Cherry Studio mobile", + "no_devices": "[to be translated]:No LAN peers found yet", + "scan_devices": "[to be translated]:Scan devices", "scan_qr": "Пожалуйста, отсканируйте QR-код с помощью вашего телефона", + "scanning_hint": "[to be translated]:Scanning your local network for Cherry Studio peers...", "selectZip": "Выберите архив", "sendZip": "Начать восстановление данных", + "send_file": "[to be translated]:Send File", "status": { "completed": "Перевод завершён", "connected": "Подключено", @@ -3189,6 +3217,9 @@ "sending": "Передача {{progress}}%", "waiting_qr_scan": "Пожалуйста, отсканируйте QR-код для подключения" }, + "status_badge_idle": "[to be translated]:Idle", + "status_badge_scanning": "[to be translated]:Scanning", + "stop_scan": "[to be translated]:Stop scan", "title": "Передача по локальной сети", "transfer_progress": "Прогресс передачи" }, diff --git a/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx b/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx index b72db6fd6..6c111fcda 100644 --- a/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx @@ -11,7 +11,7 @@ import { NutstoreIcon } from '@renderer/components/Icons/NutstoreIcons' import { HStack } from '@renderer/components/Layout' import ListItem from '@renderer/components/ListItem' import BackupPopup from '@renderer/components/Popups/BackupPopup' -import ExportToPhoneLanPopup from '@renderer/components/Popups/ExportToPhoneLanPopup' +import LanTransferPopup from '@renderer/components/Popups/LanTransferPopup' import RestorePopup from '@renderer/components/Popups/RestorePopup' import { useTheme } from '@renderer/context/ThemeProvider' import { useKnowledgeFiles } from '@renderer/hooks/useKnowledgeFiles' @@ -628,11 +628,12 @@ const DataSettings: FC = () => { {t('settings.data.export_to_phone.title')} - + {t('settings.data.data.title')} diff --git a/yarn.lock b/yarn.lock index a9bccb17e..09a07d333 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4438,6 +4438,13 @@ __metadata: languageName: node linkType: hard +"@leichtgewicht/ip-codec@npm:^2.0.1": + version: 2.0.5 + resolution: "@leichtgewicht/ip-codec@npm:2.0.5" + checksum: 10c0/14a0112bd59615eef9e3446fea018045720cd3da85a98f801a685a818b0d96ef2a1f7227e8d271def546b2e2a0fe91ef915ba9dc912ab7967d2317b1a051d66b + languageName: node + linkType: hard + "@lezer/common@npm:^1.0.0, @lezer/common@npm:^1.0.2, @lezer/common@npm:^1.0.3, @lezer/common@npm:^1.1.0, @lezer/common@npm:^1.2.0, @lezer/common@npm:^1.2.1": version: 1.2.3 resolution: "@lezer/common@npm:1.2.3" @@ -10177,6 +10184,7 @@ __metadata: archiver: "npm:^7.0.1" async-mutex: "npm:^0.5.0" axios: "npm:^1.7.3" + bonjour-service: "npm:^1.3.0" browser-image-compression: "npm:^2.0.2" chardet: "npm:^2.1.0" check-disk-space: "npm:3.4.0" @@ -11129,6 +11137,16 @@ __metadata: languageName: node linkType: hard +"bonjour-service@npm:^1.3.0": + version: 1.3.0 + resolution: "bonjour-service@npm:1.3.0" + dependencies: + fast-deep-equal: "npm:^3.1.3" + multicast-dns: "npm:^7.2.5" + checksum: 10c0/5721fd9f9bb968e9cc16c1e8116d770863dd2329cb1f753231de1515870648c225142b7eefa71f14a5c22bc7b37ddd7fdeb018700f28a8c936d50d4162d433c7 + languageName: node + linkType: hard + "boolbase@npm:^1.0.0": version: 1.0.0 resolution: "boolbase@npm:1.0.0" @@ -13330,6 +13348,15 @@ __metadata: languageName: node linkType: hard +"dns-packet@npm:^5.2.2": + version: 5.6.1 + resolution: "dns-packet@npm:5.6.1" + dependencies: + "@leichtgewicht/ip-codec": "npm:^2.0.1" + checksum: 10c0/8948d3d03063fb68e04a1e386875f8c3bcc398fc375f535f2b438fad8f41bf1afa6f5e70893ba44f4ae884c089247e0a31045722fa6ff0f01d228da103f1811d + languageName: node + linkType: hard + "doctrine@npm:3.0.0": version: 3.0.0 resolution: "doctrine@npm:3.0.0" @@ -19504,6 +19531,18 @@ __metadata: languageName: node linkType: hard +"multicast-dns@npm:^7.2.5": + version: 7.2.5 + resolution: "multicast-dns@npm:7.2.5" + dependencies: + dns-packet: "npm:^5.2.2" + thunky: "npm:^1.0.2" + bin: + multicast-dns: cli.js + checksum: 10c0/5120171d4bdb1577764c5afa96e413353bff530d1b37081cb29cccc747f989eb1baf40574fe8e27060fc1aef72b59c042f72b9b208413de33bcf411343c69057 + languageName: node + linkType: hard + "mustache@npm:^4.2.0": version: 4.2.0 resolution: "mustache@npm:4.2.0" @@ -24353,6 +24392,13 @@ __metadata: languageName: node linkType: hard +"thunky@npm:^1.0.2": + version: 1.1.0 + resolution: "thunky@npm:1.1.0" + checksum: 10c0/369764f39de1ce1de2ba2fa922db4a3f92e9c7f33bcc9a713241bc1f4a5238b484c17e0d36d1d533c625efb00e9e82c3e45f80b47586945557b45abb890156d2 + languageName: node + linkType: hard + "tiktok-video-element@npm:^0.1.0": version: 0.1.1 resolution: "tiktok-video-element@npm:0.1.1" From 8bab2c8ebc98bb6a98fa74ced8b67dc2afc554f9 Mon Sep 17 00:00:00 2001 From: eeee0717 Date: Wed, 17 Dec 2025 21:50:45 +0800 Subject: [PATCH 2/6] chore: update docs and tests --- docs/zh/references/lan-transfer-protocol.md | 327 +++++++----------- packages/shared/config/types.ts | 2 +- .../__tests__/handlers/connection.test.ts | 2 +- .../lanTransfer/handlers/connection.ts | 2 +- 4 files changed, 121 insertions(+), 212 deletions(-) diff --git a/docs/zh/references/lan-transfer-protocol.md b/docs/zh/references/lan-transfer-protocol.md index e75b114d8..5addd6201 100644 --- a/docs/zh/references/lan-transfer-protocol.md +++ b/docs/zh/references/lan-transfer-protocol.md @@ -1,6 +1,6 @@ # Cherry Studio 局域网传输协议规范 -> 版本: 3.0 +> 版本: 1.0 > 最后更新: 2025-12 本文档定义了 Cherry Studio 桌面客户端(Electron)与移动端(Expo)之间的局域网文件传输协议。 @@ -31,7 +31,7 @@ | **Client(客户端)** | Electron 桌面端 | 扫描服务、发起连接、发送文件 | | **Server(服务端)** | Expo 移动端 | 发布服务、接受连接、接收文件 | -### 1.2 协议栈(v3) +### 1.2 协议栈(v1) ``` ┌─────────────────────────────────────┐ @@ -39,7 +39,6 @@ ├─────────────────────────────────────┤ │ 消息层(控制: JSON \n) │ │ (数据: 二进制帧) │ -│ v3: 流式传输,无逐块确认 │ ├─────────────────────────────────────┤ │ 传输层(TCP) │ ├─────────────────────────────────────┤ @@ -51,15 +50,11 @@ ``` 1. 服务发现 → 移动端发布 mDNS 服务,桌面端扫描发现 -2. TCP 握手 → 建立连接,交换设备信息(`version=3`) -3. 文件传输 → 控制消息使用 JSON,`file_chunk` 使用二进制帧连续发送(无需等待确认) +2. TCP 握手 → 建立连接,交换设备信息(`version=1`) +3. 文件传输 → 控制消息使用 JSON,`file_chunk` 使用二进制帧分块传输 4. 连接保活 → ping/pong 心跳 ``` -### 1.4 安全说明 - -该协议默认运行在**可信局域网**环境:协议本身不提供传输加密、身份鉴权或防中间人攻击能力。若需要在不可信网络使用,建议在握手阶段增加一次性配对码/Token,并在传输层使用 TLS 或对文件数据进行端到端加密。 - --- ## 2. 服务发现(Bonjour/mDNS) @@ -84,7 +79,7 @@ protocol: "tcp", // 协议 port: 53317, // TCP 监听端口 txt: { // TXT 记录(可选) - version: "3", + version: "1", platform: "ios" // 或 "android" } } @@ -129,7 +124,7 @@ const preferredAddress = addresses.find((addr) => isIPv4(addr)) || addresses[0]; 2. 连接成功后立即发送握手消息 3. 等待服务端响应握手确认 -### 3.2 握手消息(协议版本 v3) +### 3.2 握手消息(协议版本 v1) #### Client → Server: `handshake` @@ -137,7 +132,7 @@ const preferredAddress = addresses.find((addr) => isIPv4(addr)) || addresses[0]; type LanTransferHandshakeMessage = { type: "handshake"; deviceName: string; // 设备名称 - version: string; // 协议版本,当前为 "3" + version: string; // 协议版本,当前为 "1" platform?: string; // 平台:'darwin' | 'win32' | 'linux' appVersion?: string; // 应用版本 }; @@ -149,7 +144,7 @@ type LanTransferHandshakeMessage = { { "type": "handshake", "deviceName": "Cherry Studio 1.7.2", - "version": "3", + "version": "1", "platform": "darwin", "appVersion": "1.7.2" } @@ -157,10 +152,10 @@ type LanTransferHandshakeMessage = { ### 4. 消息格式规范(混合协议) -v3 采用"控制 JSON + 二进制数据帧"的混合协议,支持流式传输: +v1 使用"控制 JSON + 二进制数据帧"的混合协议(流式传输模式,无 per-chunk ACK): -- **控制消息**(握手、心跳、file_start/ack、file_end、file_complete、file_cancel):UTF-8 JSON,`\n` 分隔 -- **数据消息**(`file_chunk`):二进制帧,使用 Magic + 总长度做分帧,连续发送无需等待确认 +- **控制消息**(握手、心跳、file_start/ack、file_end、file_complete):UTF-8 JSON,`\n` 分隔 +- **数据消息**(`file_chunk`):二进制帧,使用 Magic + 总长度做分帧,不经 Base64 ### 4.1 控制消息编码(JSON + `\n`) @@ -209,26 +204,25 @@ function sendControlMessage(socket: Socket, message: object): void { 3. 否则若首字节为 `{` → 按 JSON + `\n` 解析控制消息 4. 其它数据丢弃 1 字节并继续循环,避免阻塞。 -### 4.4 消息类型汇总(v3) +### 4.4 消息类型汇总(v1) -| 类型 | 方向 | 编码 | 用途 | -| ---------------- | --------------- | -------- | ------------------------------ | -| `handshake` | Client → Server | JSON+\n | 握手请求(version=3) | -| `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 | 二进制帧 | 文件数据块(连续发送,无确认) | -| `file_end` | Client → Server | JSON+\n | 文件传输结束 | -| `file_complete` | Server → Client | JSON+\n | 传输完成结果 | -| `file_cancel` | Client → Server | JSON+\n | 取消传输 | +| 类型 | 方向 | 编码 | 用途 | +| ---------------- | --------------- | -------- | ----------------------- | +| `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.5 消息发送 +### 4.3 消息发送 ```typescript function sendMessage(socket: Socket, message: object): void { @@ -237,63 +231,48 @@ function sendMessage(socket: Socket, message: object): void { } ``` -### 4.6 消息接收与解析(v3 混合协议) +### 4.4 消息接收与解析 ```typescript -const MAGIC = Buffer.from([0x43, 0x53]); // "CS" -let buffer = Buffer.alloc(0); +let buffer = ""; socket.on("data", (chunk: Buffer) => { - buffer = Buffer.concat([buffer, chunk]); + buffer += chunk.toString("utf8"); - while (buffer.length > 0) { - // 检查是否为二进制帧(Magic: 0x43 0x53) - if (buffer.length >= 2 && buffer[0] === 0x43 && buffer[1] === 0x53) { - // 需要至少 6 字节头(Magic + TotalLen) - if (buffer.length < 6) break; + let newlineIndex = buffer.indexOf("\n"); + while (newlineIndex !== -1) { + const line = buffer.slice(0, newlineIndex).trim(); + buffer = buffer.slice(newlineIndex + 1); - const totalLen = buffer.readUInt32BE(2); - const frameLen = 6 + totalLen; // Magic(2) + TotalLen(4) + payload - - if (buffer.length < frameLen) break; // 等待更多数据 - - // 解析二进制帧 - const type = buffer[6]; - const transferIdLen = buffer.readUInt16BE(7); - const transferId = buffer.slice(9, 9 + transferIdLen).toString("utf8"); - const chunkIndex = buffer.readUInt32BE(9 + transferIdLen); - const data = buffer.slice(13 + transferIdLen, frameLen); - - handleBinaryChunk(transferId, chunkIndex, data); - buffer = buffer.slice(frameLen); + if (line.length > 0) { + const message = JSON.parse(line); + handleMessage(message); } - // 检查是否为 JSON 控制消息(以 '{' 开头) - else if (buffer[0] === 0x7b) { - // '{' = 0x7b - const newlineIndex = buffer.indexOf(0x0a); // '\n' = 0x0a - if (newlineIndex === -1) break; // 等待完整的 JSON 行 - const line = buffer.slice(0, newlineIndex).toString("utf8").trim(); - buffer = buffer.slice(newlineIndex + 1); - - if (line.length > 0) { - const message = JSON.parse(line); - handleMessage(message); - } - } - // 其他数据丢弃 1 字节,继续解析 - else { - buffer = buffer.slice(1); - } + 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 传输流程(v3 流式传输) +### 5.1 传输流程 ``` Client (Sender) Server (Receiver) @@ -304,25 +283,23 @@ Client (Sender) Server (Receiver) |<─── 2. file_start_ack ─────────────| | (接受/拒绝) | | | - |══════ 连续发送数据块(无确认)═══════| + |══════ 循环发送数据块(流式,无 ACK) ═════| | | |──── 3. file_chunk [0] ────────────>| + | | |──── 3. file_chunk [1] ────────────>| - |──── 3. file_chunk [2] ────────────>| - | ... 连续发送所有块 ... | - |──── 3. file_chunk [N-1] ──────────>| + | | + | ... 重复直到所有块发送完成 ... | | | |══════════════════════════════════════ | | - |──── 4. file_end ──────────────────>| + |──── 5. file_end ──────────────────>| | (所有块已发送) | | | - |<─── 5. file_complete ──────────────| - | (校验和验证结果) | + |<─── 6. file_complete ──────────────| + | (最终结果) | ``` -> **v3 特性**: 数据块连续发送,无需等待每块确认。接收端在收到 `file_end` 后验证完整性,通过 `file_complete` 返回最终结果。 - ### 5.2 消息定义 #### 5.2.1 `file_start` - 开始传输 @@ -352,8 +329,8 @@ type LanTransferFileStartMessage = { "fileSize": 524288000, "mimeType": "application/zip", "checksum": "a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456", - "totalChunks": 1000, - "chunkSize": 524288 + "totalChunks": 8192, + "chunkSize": 65536 } ``` @@ -400,7 +377,11 @@ type LanTransferFileStartAckMessage = { - `Type` 固定 `0x01`,`Data` 为原始文件二进制数据 - 传输完整性依赖 `file_start.checksum`(全文件 SHA-256);分块校验和可选,不在帧中发送 -#### 5.2.4 `file_end` - 传输结束 +#### 5.2.4 `file_chunk_ack` - 数据块确认(v1 流式不使用) + +v1 采用流式传输,不发送 per-chunk ACK。本节类型仅保留作为向后兼容参考,实际不会发送。 + +#### 5.2.5 `file_end` - 传输结束 **方向:** Client → Server @@ -420,7 +401,7 @@ type LanTransferFileEndMessage = { } ``` -#### 5.2.5 `file_complete` - 传输完成 +#### 5.2.6 `file_complete` - 传输完成 **方向:** Server → Client @@ -431,26 +412,9 @@ type LanTransferFileCompleteMessage = { success: boolean; // 是否成功 filePath?: string; // 保存路径(成功时) error?: string; // 错误信息(失败时) - // v3 新增字段 - errorCode?: - | "CHECKSUM_MISMATCH" - | "INCOMPLETE_TRANSFER" - | "DISK_ERROR" - | "CANCELLED"; - receivedChunks?: number; // 实际接收的数据块数量 - receivedBytes?: number; // 实际接收的字节数 }; ``` -**错误码说明:** - -| 错误码 | 说明 | -| --------------------- | -------------------------- | -| `CHECKSUM_MISMATCH` | 校验和不匹配 | -| `INCOMPLETE_TRANSFER` | 传输不完整,缺少数据块 | -| `DISK_ERROR` | 磁盘写入错误或存储空间不足 | -| `CANCELLED` | 传输被取消 | - **成功示例:** ```json @@ -458,9 +422,7 @@ type LanTransferFileCompleteMessage = { "type": "file_complete", "transferId": "550e8400-e29b-41d4-a716-446655440000", "success": true, - "filePath": "/storage/emulated/0/Documents/backup.zip", - "receivedChunks": 1000, - "receivedBytes": 524288000 + "filePath": "/storage/emulated/0/Documents/backup.zip" } ``` @@ -471,32 +433,7 @@ type LanTransferFileCompleteMessage = { "type": "file_complete", "transferId": "550e8400-e29b-41d4-a716-446655440000", "success": false, - "error": "File checksum verification failed", - "errorCode": "CHECKSUM_MISMATCH", - "receivedChunks": 1000, - "receivedBytes": 524288000 -} -``` - -#### 5.2.6 `file_cancel` - 取消传输 - -**方向:** Client → Server - -```typescript -type LanTransferFileCancelMessage = { - type: "file_cancel"; - transferId: string; // 传输 ID - reason?: string; // 取消原因 -}; -``` - -**示例:** - -```json -{ - "type": "file_cancel", - "transferId": "550e8400-e29b-41d4-a716-446655440000", - "reason": "Cancelled by user" + "error": "File checksum verification failed" } ``` @@ -519,7 +456,7 @@ async function calculateFileChecksum(filePath: string): Promise { #### 数据块校验和 -v3 默认 **不传输分块校验和**,依赖最终文件 checksum。若需要,可在应用层自定义(非协议字段)。 +v1 默认 **不传输分块校验和**,依赖最终文件 checksum。若需要,可在应用层自定义(非协议字段)。 ### 5.4 校验流程 @@ -602,25 +539,22 @@ type LanTransferPongMessage = { ### 7.1 超时配置 -| 操作 | 超时时间 | 说明 | -| -------- | -------- | -------------------- | -| TCP 连接 | 10 秒 | 连接建立超时 | -| 握手等待 | 10 秒 | 等待 `handshake_ack` | -| 传输完成 | 60 秒 | 等待 `file_complete` | -| 全局超时 | 10 分钟 | 整个文件传输超时 | +| 操作 | 超时时间 | 说明 | +| ---------- | -------- | --------------------- | +| TCP 连接 | 10 秒 | 连接建立超时 | +| 握手等待 | 10 秒 | 等待 `handshake_ack` | +| 传输完成 | 60 秒 | 等待 `file_complete` | ### 7.2 错误场景处理 -| 场景 | Client 处理 | Server 处理 | -| ------------ | ------------------------ | ------------------------------- | -| TCP 连接失败 | 通知 UI,允许重试 | - | -| 握手超时 | 断开连接,通知 UI | 关闭 socket | -| 握手被拒绝 | 显示拒绝原因 | - | -| 用户取消 | 发送 `file_cancel`,清理 | 清理临时文件 | -| 连接意外断开 | 清理状态,通知 UI | 清理临时文件 | -| 存储空间不足 | - | 发送 `accepted: false` | -| 校验和失败 | 显示错误信息 | 发送 `file_complete` 带错误码 | -| 数据块缺失 | 显示错误信息 | 发送 `INCOMPLETE_TRANSFER` 错误 | +| 场景 | Client 处理 | Server 处理 | +| --------------- | ------------------ | ---------------------- | +| TCP 连接失败 | 通知 UI,允许重试 | - | +| 握手超时 | 断开连接,通知 UI | 关闭 socket | +| 握手被拒绝 | 显示拒绝原因 | - | +| 数据块处理失败 | 中止传输,清理状态 | 清理临时文件 | +| 连接意外断开 | 清理状态,通知 UI | 清理临时文件 | +| 存储空间不足 | - | 发送 `accepted: false` | ### 7.3 资源清理 @@ -663,8 +597,8 @@ function cleanup(): void { ### 8.1 协议常量 ```typescript -// 协议版本(v3 = 控制 JSON + 二进制 chunk 流式传输) -export const LAN_TRANSFER_PROTOCOL_VERSION = "3"; +// 协议版本(v1 = 控制 JSON + 二进制 chunk + 流式传输) +export const LAN_TRANSFER_PROTOCOL_VERSION = "1"; // 服务发现 export const LAN_TRANSFER_SERVICE_TYPE = "cherrystudio"; @@ -675,14 +609,11 @@ export const LAN_TRANSFER_TCP_PORT = 53317; // 文件传输(与二进制帧一致) export const LAN_TRANSFER_CHUNK_SIZE = 512 * 1024; // 512KB -export const LAN_TRANSFER_MAX_FILE_SIZE = 500 * 1024 * 1024; // 500MB export const LAN_TRANSFER_GLOBAL_TIMEOUT_MS = 10 * 60 * 1000; // 10 分钟 -// 注意:接收端必须支持至少 512KB 的分片大小,否则会拒收并返回类似 -// "Chunk size 524288 exceeds limit 65536" 的错误。 - // 超时设置 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秒 ``` @@ -702,7 +633,7 @@ export const LAN_TRANSFER_ALLOWED_MIME_TYPES = [ ## 9. 完整时序图 -### 9.1 完整传输流程(v3,流式传输) +### 9.1 完整传输流程(v1,流式传输) ``` ┌─────────┐ ┌─────────┐ ┌─────────┐ @@ -727,7 +658,7 @@ export const LAN_TRANSFER_ALLOWED_MIME_TYPES = [ │────────────────────────────────────>│ │ │ │──────── TCP Connect ───────────────>│ │ │ │ - │ │──────── handshake (v=3) ───────────>│ + │ │──────── handshake ─────────────────>│ │ │ │ │ │<─────── handshake_ack ──────────────│ │ │ │ @@ -744,20 +675,21 @@ export const LAN_TRANSFER_ALLOWED_MIME_TYPES = [ │ │ │ │ │<─────── file_start_ack ─────────────│ │ │ │ - │ │══════ 连续发送数据块(无确认)═══════│ + │ │ │ + │ │══════ 循环发送数据块 ═══════════════│ │ │ │ │ │──────── file_chunk[0] (binary) ────>│ + │<────── progress event ──────────────│ │ + │ │ │ │ │──────── file_chunk[1] (binary) ────>│ - │ │──────── file_chunk[2] (binary) ────>│ - │<────── progress event ──────────────│ ... 连续发送 ... │ - │ │──────── file_chunk[N-1] (binary) ──>│ + │<────── progress event ──────────────│ │ + │ │ │ + │ │ ... 重复 ... │ │ │ │ │ │══════════════════════════════════════│ │ │ │ │ │──────── file_end ──────────────────>│ │ │ │ - │ │ (接收端验证 checksum) │ - │ │ │ │ │<─────── file_complete ──────────────│ │ │ │ │<────── complete event ──────────────│ │ @@ -765,11 +697,9 @@ export const LAN_TRANSFER_ALLOWED_MIME_TYPES = [ │ │ │ ``` -> **v3 特性**: 数据块连续发送,不等待单个确认,大幅提高传输速度。接收端在 `file_end` 后统一验证完整性。 - --- -## 10. 移动端实现指南(v3 要点) +## 10. 移动端实现指南(v1 要点) ### 10.1 必须实现的功能 @@ -791,21 +721,16 @@ export const LAN_TRANSFER_ALLOWED_MIME_TYPES = [ 4. **握手处理** - - 验证 `handshake` 消息(version=3) + - 验证 `handshake` 消息 - 发送 `handshake_ack` 响应 - 响应 `ping` 消息 -5. **文件接收(v3 流式模式)** - +5. **文件接收(流式模式)** - 解析 `file_start`,准备接收 - - 接收 `file_chunk` 二进制帧,直接写入文件并增量计算哈希 - - **无需发送 `file_chunk_ack`**(v3 移除了逐块确认) + - 接收 `file_chunk` 二进制帧,直接写入文件/缓冲并增量哈希 + - v1 不发送 per-chunk ACK(流式传输) - 处理 `file_end`,完成增量哈希并校验 checksum - - 发送 `file_complete` 结果(包含 errorCode、receivedChunks、receivedBytes) - -6. **取消处理** - - 监听 `file_cancel` 消息 - - 清理临时文件和状态 + - 发送 `file_complete` 结果 ### 10.2 推荐的库 @@ -827,7 +752,7 @@ class FileReceiver { totalChunks: number; receivedChunks: number; tempPath: string; - // v3: 边收边写文件,避免大文件 OOM + // v1: 边收边写文件,避免大文件 OOM // stream: FileSystem writable stream (平台相关封装) }; @@ -842,13 +767,10 @@ class FileReceiver { case "file_start": this.handleFileStart(message); break; - // v3: file_chunk 为二进制帧,不走 JSON 分支 + // v1: file_chunk 为二进制帧,不再走 JSON 分支 case "file_end": this.handleFileEnd(message); break; - case "file_cancel": - this.handleFileCancel(message); - break; } } @@ -859,12 +781,12 @@ class FileReceiver { // 4. 发送 file_start_ack } - // v3: 二进制帧处理在 socket data 流中解析,随后调用 handleBinaryFileChunk + // v1: 二进制帧处理在 socket data 流中解析,随后调用 handleBinaryFileChunk handleBinaryFileChunk(transferId: string, chunkIndex: number, data: Buffer) { // 直接使用二进制数据,按 chunkSize/lastChunk 计算长度 // 写入文件流并更新增量 SHA-256 this.transfer.receivedChunks++; - // v3: 无需发送 file_chunk_ack,连续接收数据块即可 + // v1: 流式传输,不发送 per-chunk ACK } handleFileEnd(msg: LanTransferFileEndMessage) { @@ -922,13 +844,11 @@ export interface LanTransferFileStartMessage { chunkSize: number; } -// v3: file_chunk 以二进制帧传输,不是 JSON 消息 -// 帧格式: Magic(2B) + TotalLen(4B) + Type(1B, 0x01) + TransferIdLen(2B) + TransferId(nB) + ChunkIndex(4B) + Data -export interface LanTransferFileChunkBinaryFrame { - type: 0x01; // 二进制帧类型标识 (file_chunk) +export interface LanTransferFileChunkMessage { + type: "file_chunk"; transferId: string; chunkIndex: number; - data: Buffer; // 原始二进制数据 + data: string; // Base64 encoded (v1: 二进制帧模式下不使用) } export interface LanTransferFileEndMessage { @@ -936,12 +856,6 @@ export interface LanTransferFileEndMessage { transferId: string; } -export interface LanTransferFileCancelMessage { - type: "file_cancel"; - transferId: string; - reason?: string; -} - // 文件传输响应消息 (Server -> Client) export interface LanTransferFileStartAckMessage { type: "file_start_ack"; @@ -950,7 +864,14 @@ export interface LanTransferFileStartAckMessage { message?: string; } -// v3: 移除了 LanTransferFileChunkAckMessage(流式传输无需逐块确认) +// 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"; @@ -958,30 +879,18 @@ export interface LanTransferFileCompleteMessage { success: boolean; filePath?: string; error?: string; - // v3 新增字段 - errorCode?: - | "CHECKSUM_MISMATCH" - | "INCOMPLETE_TRANSFER" - | "DISK_ERROR" - | "CANCELLED"; - receivedChunks?: number; - receivedBytes?: number; } // 常量 -export const LAN_TRANSFER_PROTOCOL_VERSION = "3"; export const LAN_TRANSFER_TCP_PORT = 53317; export const LAN_TRANSFER_CHUNK_SIZE = 512 * 1024; -export const LAN_TRANSFER_MAX_FILE_SIZE = 500 * 1024 * 1024; -// v3: 移除了 CHUNK_TIMEOUT_MS(流式传输无需逐块等待超时) +export const LAN_TRANSFER_CHUNK_TIMEOUT_MS = 30_000; ``` --- ## 附录 B:版本历史 -| 版本 | 日期 | 变更 | -| ---- | ------- | ------------------------------------------------------------------ | -| 1.0 | 2025-12 | 初始版本,与移动端实现对齐 | -| 2.0 | 2025-12 | 引入二进制帧格式传输数据块,仍使用逐块 ACK 确认 | -| 3.0 | 2025-12 | 流式传输模式,移除 `file_chunk_ack`,增强 `file_complete` 错误诊断 | +| 版本 | 日期 | 变更 | +| ---- | ------- | ---------------------------------------- | +| 1.0 | 2025-12 | 初始发布版本,支持二进制帧格式与流式传输 | diff --git a/packages/shared/config/types.ts b/packages/shared/config/types.ts index b6d79822c..0cc811f6d 100644 --- a/packages/shared/config/types.ts +++ b/packages/shared/config/types.ts @@ -162,7 +162,7 @@ export const LAN_TRANSFER_COMPLETE_TIMEOUT_MS = 60_000 // 60s - wait for file_co export const LAN_TRANSFER_GLOBAL_TIMEOUT_MS = 10 * 60 * 1000 // 10 minutes - global transfer timeout // Binary protocol constants (v3) -export const LAN_TRANSFER_PROTOCOL_VERSION = '3' +export const LAN_TRANSFER_PROTOCOL_VERSION = '1' export const LAN_BINARY_FRAME_MAGIC = 0x4353 // "CS" as uint16 export const LAN_BINARY_TYPE_FILE_CHUNK = 0x01 diff --git a/src/main/services/lanTransfer/__tests__/handlers/connection.test.ts b/src/main/services/lanTransfer/__tests__/handlers/connection.test.ts index e889dc95a..9ac98f23a 100644 --- a/src/main/services/lanTransfer/__tests__/handlers/connection.test.ts +++ b/src/main/services/lanTransfer/__tests__/handlers/connection.test.ts @@ -29,7 +29,7 @@ describe('connection handlers', () => { }) it('should use protocol version 3', () => { - expect(HANDSHAKE_PROTOCOL_VERSION).toBe('3') + expect(HANDSHAKE_PROTOCOL_VERSION).toBe('1') }) }) diff --git a/src/main/services/lanTransfer/handlers/connection.ts b/src/main/services/lanTransfer/handlers/connection.ts index 14340c74a..c3e244bbb 100644 --- a/src/main/services/lanTransfer/handlers/connection.ts +++ b/src/main/services/lanTransfer/handlers/connection.ts @@ -7,7 +7,7 @@ import { app } from 'electron' import type { ConnectionContext } from '../types' -export const HANDSHAKE_PROTOCOL_VERSION = '3' +export const HANDSHAKE_PROTOCOL_VERSION = '1' const logger = loggerService.withContext('LanTransferConnection') From 0dc9658846f036521afc59bab470bdec1e90762b Mon Sep 17 00:00:00 2001 From: eeee0717 Date: Thu, 18 Dec 2025 09:48:52 +0800 Subject: [PATCH 3/6] fix: pr review --- src/main/services/BackupManager.ts | 7 ++-- .../lanTransfer/LanTransferClientService.ts | 41 +++++++++++++++++-- .../services/lanTransfer/binaryProtocol.ts | 4 ++ .../lanTransfer/handlers/connection.ts | 11 +++++ .../lanTransfer/handlers/fileTransfer.ts | 13 +++++- 5 files changed, 67 insertions(+), 9 deletions(-) diff --git a/src/main/services/BackupManager.ts b/src/main/services/BackupManager.ts index 5af4c9f67..46b78ed5a 100644 --- a/src/main/services/BackupManager.ts +++ b/src/main/services/BackupManager.ts @@ -797,10 +797,11 @@ class BackupManager { async deleteTempBackup(_: Electron.IpcMainInvokeEvent, filePath: string): Promise { try { // Security check: only allow deletion within temp directory - const tempBase = path.join(app.getPath('temp'), 'cherry-studio', 'lan-transfer') - const resolvedPath = path.resolve(filePath) + const tempBase = path.normalize(path.join(app.getPath('temp'), 'cherry-studio', 'lan-transfer')) + const resolvedPath = path.normalize(path.resolve(filePath)) - if (!resolvedPath.startsWith(tempBase)) { + // Use normalized paths with trailing separator to prevent prefix attacks (e.g., /temp-evil) + if (!resolvedPath.startsWith(tempBase + path.sep) && resolvedPath !== tempBase) { logger.warn(`[BackupManager] Attempted to delete file outside temp directory: ${filePath}`) return false } diff --git a/src/main/services/lanTransfer/LanTransferClientService.ts b/src/main/services/lanTransfer/LanTransferClientService.ts index fb8019248..a9335e8f4 100644 --- a/src/main/services/lanTransfer/LanTransferClientService.ts +++ b/src/main/services/lanTransfer/LanTransferClientService.ts @@ -53,6 +53,9 @@ class LanTransferClientService { private isConnecting = false private activeTransfer?: ActiveFileTransfer private lastConnectOptions?: LocalTransferConnectPayload + private consecutiveJsonErrors = 0 + private static readonly MAX_CONSECUTIVE_JSON_ERRORS = 3 + private reconnectPromise: Promise | null = null constructor() { this.responseManager.setTimeoutCallback(() => void this.disconnect()) @@ -296,8 +299,9 @@ class LanTransferClientService { transferId, reason: 'Cancelled by user' }) - } catch { - // Ignore errors when sending cancel message + } catch (error) { + // Expected when connection is already broken + logger.warn('Failed to send cancel message', error as Error) } abortTransfer(this.activeTransfer, new Error('Transfer cancelled by user')) @@ -317,8 +321,24 @@ class LanTransferClientService { throw new Error('No active connection. Please connect to a peer first.') } + // Prevent concurrent reconnection attempts + if (this.reconnectPromise) { + logger.debug('Waiting for existing reconnection attempt...') + await this.reconnectPromise + return + } + logger.info('Connection lost, attempting to reconnect...') - await this.connectAndHandshake(this.lastConnectOptions) + this.reconnectPromise = this.connectAndHandshake(this.lastConnectOptions) + .then(() => { + this.reconnectPromise = null + }) + .catch((error) => { + this.reconnectPromise = null + throw error + }) + + await this.reconnectPromise } private async performFileTransfer( @@ -393,8 +413,21 @@ class LanTransferClientService { let payload: Record try { payload = JSON.parse(line) + this.consecutiveJsonErrors = 0 // Reset on successful parse } catch { - logger.warn('Received invalid JSON control message', { line }) + this.consecutiveJsonErrors++ + logger.warn('Received invalid JSON control message', { line, consecutiveErrors: this.consecutiveJsonErrors }) + + if (this.consecutiveJsonErrors >= LanTransferClientService.MAX_CONSECUTIVE_JSON_ERRORS) { + const message = `Protocol error: ${this.consecutiveJsonErrors} consecutive invalid messages` + logger.error(message) + this.broadcastClientEvent({ + type: 'error', + message, + timestamp: Date.now() + }) + this.consecutiveJsonErrors = 0 + } return } diff --git a/src/main/services/lanTransfer/binaryProtocol.ts b/src/main/services/lanTransfer/binaryProtocol.ts index a3e16e776..fc1f57a0b 100644 --- a/src/main/services/lanTransfer/binaryProtocol.ts +++ b/src/main/services/lanTransfer/binaryProtocol.ts @@ -23,6 +23,10 @@ export const BINARY_TYPE_FILE_CHUNK = 0x01 * @returns true if data was buffered, false if backpressure should be applied */ export function sendBinaryChunk(socket: Socket, transferId: string, chunkIndex: number, data: Buffer): boolean { + if (!socket || socket.destroyed || !socket.writable) { + throw new Error('Socket is not writable') + } + const tidBuffer = Buffer.from(transferId, 'utf8') const tidLen = tidBuffer.length diff --git a/src/main/services/lanTransfer/handlers/connection.ts b/src/main/services/lanTransfer/handlers/connection.ts index c3e244bbb..5a53eeb37 100644 --- a/src/main/services/lanTransfer/handlers/connection.ts +++ b/src/main/services/lanTransfer/handlers/connection.ts @@ -9,6 +9,9 @@ import type { ConnectionContext } from '../types' export const HANDSHAKE_PROTOCOL_VERSION = '1' +/** Maximum size for line buffer to prevent memory exhaustion from malicious peers */ +const MAX_LINE_BUFFER_SIZE = 1024 * 1024 // 1MB limit for control messages + const logger = loggerService.withContext('LanTransferConnection') /** @@ -74,6 +77,14 @@ export function createDataHandler(onControlLine: (line: string) => void): { }, handleData(chunk: Buffer) { lineBuffer += chunk.toString('utf8') + + // Prevent memory exhaustion from malicious peers sending data without newlines + if (lineBuffer.length > MAX_LINE_BUFFER_SIZE) { + logger.error('Line buffer exceeded maximum size, resetting') + lineBuffer = '' + throw new Error('Control message too large') + } + let newlineIndex = lineBuffer.indexOf('\n') while (newlineIndex !== -1) { const line = lineBuffer.slice(0, newlineIndex).trim() diff --git a/src/main/services/lanTransfer/handlers/fileTransfer.ts b/src/main/services/lanTransfer/handlers/fileTransfer.ts index 733de9ec6..86ea61e13 100644 --- a/src/main/services/lanTransfer/handlers/fileTransfer.ts +++ b/src/main/services/lanTransfer/handlers/fileTransfer.ts @@ -32,8 +32,17 @@ export async function validateFile(filePath: string): Promise<{ stats: fs.Stats; let stats: fs.Stats try { stats = await fs.promises.stat(filePath) - } catch { - throw new Error(`File not found: ${filePath}`) + } catch (error) { + const nodeError = error as NodeJS.ErrnoException + if (nodeError.code === 'ENOENT') { + throw new Error(`File not found: ${filePath}`) + } else if (nodeError.code === 'EACCES') { + throw new Error(`Permission denied: ${filePath}`) + } else if (nodeError.code === 'ENOTDIR') { + throw new Error(`Invalid path: ${filePath}`) + } else { + throw new Error(`Cannot access file: ${filePath} (${nodeError.code || 'unknown error'})`) + } } if (!stats.isFile()) { From fc92f356ed90d80fe2c55cd5e638cf051a36badf Mon Sep 17 00:00:00 2001 From: eeee0717 Date: Thu, 18 Dec 2025 10:06:34 +0800 Subject: [PATCH 4/6] fix: pr review --- .../lanTransfer/LanTransferClientService.ts | 2 +- .../LanTransferClientService.test.ts | 137 ++++++++++++++++++ .../__tests__/binaryProtocol.test.ts | 14 ++ .../__tests__/handlers/connection.test.ts | 109 +++++++++++++- .../__tests__/handlers/fileTransfer.test.ts | 62 +++++++- .../services/lanTransfer/binaryProtocol.ts | 4 +- .../lanTransfer/handlers/fileTransfer.ts | 4 +- src/main/services/lanTransfer/index.ts | 2 +- 8 files changed, 323 insertions(+), 11 deletions(-) create mode 100644 src/main/services/lanTransfer/__tests__/LanTransferClientService.test.ts diff --git a/src/main/services/lanTransfer/LanTransferClientService.ts b/src/main/services/lanTransfer/LanTransferClientService.ts index a9335e8f4..68b6a82b9 100644 --- a/src/main/services/lanTransfer/LanTransferClientService.ts +++ b/src/main/services/lanTransfer/LanTransferClientService.ts @@ -43,7 +43,7 @@ const logger = loggerService.withContext('LanTransferClientService') * LAN Transfer Client Service * * Handles outgoing file transfers to LAN peers via TCP. - * Protocol v3 with streaming mode (no per-chunk acknowledgment). + * Protocol v1 with streaming mode (no per-chunk acknowledgment). */ class LanTransferClientService { private socket: Socket | null = null diff --git a/src/main/services/lanTransfer/__tests__/LanTransferClientService.test.ts b/src/main/services/lanTransfer/__tests__/LanTransferClientService.test.ts new file mode 100644 index 000000000..60eb8476c --- /dev/null +++ b/src/main/services/lanTransfer/__tests__/LanTransferClientService.test.ts @@ -0,0 +1,137 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +// Mock dependencies before importing the service +vi.mock('node:net', async (importOriginal) => { + const actual = (await importOriginal()) as Record + return { + ...actual, + createConnection: vi.fn() + } +}) + +vi.mock('electron', () => ({ + app: { + getName: vi.fn(() => 'Cherry Studio'), + getVersion: vi.fn(() => '1.0.0') + } +})) + +vi.mock('../../LocalTransferService', () => ({ + localTransferService: { + getPeerById: vi.fn() + } +})) + +vi.mock('../../WindowService', () => ({ + windowService: { + getMainWindow: vi.fn(() => ({ + isDestroyed: () => false, + webContents: { + send: vi.fn() + } + })) + } +})) + +// Import after mocks +import { localTransferService } from '../../LocalTransferService' + +describe('LanTransferClientService', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.resetModules() + }) + + afterEach(() => { + vi.resetAllMocks() + }) + + describe('connectAndHandshake - validation', () => { + it('should throw error when peer is not found', async () => { + vi.mocked(localTransferService.getPeerById).mockReturnValue(undefined) + + const { lanTransferClientService } = await import('../LanTransferClientService') + + await expect( + lanTransferClientService.connectAndHandshake({ + peerId: 'non-existent', + type: 'connect' + }) + ).rejects.toThrow('Selected LAN peer is no longer available') + }) + + it('should throw error when peer has no port', async () => { + vi.mocked(localTransferService.getPeerById).mockReturnValue({ + id: 'test-peer', + name: 'Test Peer', + addresses: ['192.168.1.100'], + updatedAt: Date.now() + }) + + const { lanTransferClientService } = await import('../LanTransferClientService') + + await expect( + lanTransferClientService.connectAndHandshake({ + peerId: 'test-peer', + type: 'connect' + }) + ).rejects.toThrow('Selected peer does not expose a TCP port') + }) + + it('should throw error when no reachable host', async () => { + vi.mocked(localTransferService.getPeerById).mockReturnValue({ + id: 'test-peer', + name: 'Test Peer', + port: 12345, + addresses: [], + updatedAt: Date.now() + }) + + const { lanTransferClientService } = await import('../LanTransferClientService') + + await expect( + lanTransferClientService.connectAndHandshake({ + peerId: 'test-peer', + type: 'connect' + }) + ).rejects.toThrow('Unable to resolve a reachable host for the peer') + }) + + }) + + describe('cancelTransfer', () => { + it('should not throw when no active transfer', async () => { + const { lanTransferClientService } = await import('../LanTransferClientService') + + // Should not throw, just log warning + expect(() => lanTransferClientService.cancelTransfer()).not.toThrow() + }) + }) + + describe('dispose', () => { + it('should clean up resources without throwing', async () => { + const { lanTransferClientService } = await import('../LanTransferClientService') + + // Should not throw + expect(() => lanTransferClientService.dispose()).not.toThrow() + }) + }) + + describe('sendFile', () => { + it('should throw error when not connected', async () => { + const { lanTransferClientService } = await import('../LanTransferClientService') + + await expect(lanTransferClientService.sendFile('/path/to/file.zip')).rejects.toThrow( + 'No active connection. Please connect to a peer first.' + ) + }) + }) + + describe('HANDSHAKE_PROTOCOL_VERSION', () => { + it('should export protocol version', async () => { + const { HANDSHAKE_PROTOCOL_VERSION } = await import('../LanTransferClientService') + + expect(HANDSHAKE_PROTOCOL_VERSION).toBe('1') + }) + }) +}) diff --git a/src/main/services/lanTransfer/__tests__/binaryProtocol.test.ts b/src/main/services/lanTransfer/__tests__/binaryProtocol.test.ts index cf9d432df..e65a0d71a 100644 --- a/src/main/services/lanTransfer/__tests__/binaryProtocol.test.ts +++ b/src/main/services/lanTransfer/__tests__/binaryProtocol.test.ts @@ -13,6 +13,8 @@ describe('binaryProtocol', () => { beforeEach(() => { writtenBuffers = [] mockSocket = Object.assign(new EventEmitter(), { + destroyed: false, + writable: true, write: vi.fn((buffer: Buffer) => { writtenBuffers.push(Buffer.from(buffer)) return true @@ -79,6 +81,18 @@ describe('binaryProtocol', () => { const expectedTotalLen = 1 + 2 + Buffer.from(transferId).length + 4 + data.length expect(totalLen).toBe(expectedTotalLen) }) + + it('should throw error when socket is not writable', () => { + mockSocket.writable = false + + expect(() => sendBinaryChunk(mockSocket, 'test-id', 0, Buffer.from('data'))).toThrow('Socket is not writable') + }) + + it('should throw error when socket is destroyed', () => { + mockSocket.destroyed = true + + expect(() => sendBinaryChunk(mockSocket, 'test-id', 0, Buffer.from('data'))).toThrow('Socket is not writable') + }) }) describe('BINARY_TYPE_FILE_CHUNK', () => { diff --git a/src/main/services/lanTransfer/__tests__/handlers/connection.test.ts b/src/main/services/lanTransfer/__tests__/handlers/connection.test.ts index 9ac98f23a..bed36aa73 100644 --- a/src/main/services/lanTransfer/__tests__/handlers/connection.test.ts +++ b/src/main/services/lanTransfer/__tests__/handlers/connection.test.ts @@ -1,11 +1,15 @@ -import { describe, expect, it, vi } from 'vitest' +import { EventEmitter } from 'node:events' +import type { Socket } from 'node:net' + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { buildHandshakeMessage, createDataHandler, getAbortError, HANDSHAKE_PROTOCOL_VERSION, - pickHost + pickHost, + waitForSocketDrain } from '../../handlers/connection' // Mock electron app @@ -28,7 +32,7 @@ describe('connection handlers', () => { expect(typeof message.platform).toBe('string') }) - it('should use protocol version 3', () => { + it('should use protocol version 1', () => { expect(HANDSHAKE_PROTOCOL_VERSION).toBe('1') }) }) @@ -137,6 +141,105 @@ describe('connection handlers', () => { expect(lines).toEqual(['{"type":"test"}']) }) + + it('should throw error when buffer exceeds MAX_LINE_BUFFER_SIZE', () => { + const handler = createDataHandler(vi.fn()) + + // Create a buffer larger than 1MB (MAX_LINE_BUFFER_SIZE) + const largeData = 'x'.repeat(1024 * 1024 + 1) + + expect(() => handler.handleData(Buffer.from(largeData))).toThrow('Control message too large') + }) + + it('should reset buffer after exceeding MAX_LINE_BUFFER_SIZE', () => { + const lines: string[] = [] + const handler = createDataHandler((line) => lines.push(line)) + + // Create a buffer larger than 1MB + const largeData = 'x'.repeat(1024 * 1024 + 1) + + try { + handler.handleData(Buffer.from(largeData)) + } catch { + // Expected error + } + + // Buffer should be reset, so lineBuffer should be empty + expect(handler.lineBuffer).toBe('') + }) + }) + + describe('waitForSocketDrain', () => { + let mockSocket: Socket & EventEmitter + + beforeEach(() => { + mockSocket = Object.assign(new EventEmitter(), { + destroyed: false, + writable: true, + write: vi.fn(), + off: vi.fn(), + removeAllListeners: vi.fn() + }) as unknown as Socket & EventEmitter + }) + + afterEach(() => { + vi.resetAllMocks() + }) + + it('should throw error when abort signal is already aborted', async () => { + const abortController = new AbortController() + abortController.abort(new Error('Already aborted')) + + await expect(waitForSocketDrain(mockSocket, abortController.signal)).rejects.toThrow('Already aborted') + }) + + it('should throw error when socket is destroyed', async () => { + mockSocket.destroyed = true + const abortController = new AbortController() + + await expect(waitForSocketDrain(mockSocket, abortController.signal)).rejects.toThrow('Socket is closed') + }) + + it('should resolve when drain event is emitted', async () => { + const abortController = new AbortController() + + const drainPromise = waitForSocketDrain(mockSocket, abortController.signal) + + // Emit drain event after a short delay + setImmediate(() => mockSocket.emit('drain')) + + await expect(drainPromise).resolves.toBeUndefined() + }) + + it('should reject when close event is emitted', async () => { + const abortController = new AbortController() + + const drainPromise = waitForSocketDrain(mockSocket, abortController.signal) + + setImmediate(() => mockSocket.emit('close')) + + await expect(drainPromise).rejects.toThrow('Socket closed while waiting for drain') + }) + + it('should reject when error event is emitted', async () => { + const abortController = new AbortController() + + const drainPromise = waitForSocketDrain(mockSocket, abortController.signal) + + setImmediate(() => mockSocket.emit('error', new Error('Network error'))) + + await expect(drainPromise).rejects.toThrow('Network error') + }) + + it('should reject when abort signal is triggered', async () => { + const abortController = new AbortController() + + const drainPromise = waitForSocketDrain(mockSocket, abortController.signal) + + setImmediate(() => abortController.abort(new Error('User cancelled'))) + + await expect(drainPromise).rejects.toThrow('User cancelled') + }) }) describe('getAbortError', () => { diff --git a/src/main/services/lanTransfer/__tests__/handlers/fileTransfer.test.ts b/src/main/services/lanTransfer/__tests__/handlers/fileTransfer.test.ts index 4119cd044..94cf3f1f7 100644 --- a/src/main/services/lanTransfer/__tests__/handlers/fileTransfer.test.ts +++ b/src/main/services/lanTransfer/__tests__/handlers/fileTransfer.test.ts @@ -1,10 +1,28 @@ +import { EventEmitter } from 'node:events' import type * as fs from 'node:fs' +import type { Socket } from 'node:net' -import { describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { abortTransfer, cleanupTransfer, createTransferState, formatFileSize } from '../../handlers/fileTransfer' +import { abortTransfer, cleanupTransfer, createTransferState, formatFileSize, streamFileChunks } from '../../handlers/fileTransfer' import type { ActiveFileTransfer } from '../../types' +// Mock binaryProtocol +vi.mock('../../binaryProtocol', () => ({ + sendBinaryChunk: vi.fn().mockReturnValue(true) +})) + +// Mock connection handlers +vi.mock('./connection', () => ({ + waitForSocketDrain: vi.fn().mockResolvedValue(undefined), + getAbortError: vi.fn((signal, fallback) => { + const reason = (signal as AbortSignal & { reason?: unknown }).reason + if (reason instanceof Error) return reason + if (typeof reason === 'string' && reason.length > 0) return new Error(reason) + return new Error(fallback) + }) +})) + // Note: validateFile and calculateFileChecksum tests are skipped because // the test environment has globally mocked node:fs and node:os modules. // These functions are tested through integration tests instead. @@ -149,4 +167,44 @@ describe('fileTransfer handlers', () => { expect(formatFileSize(1.5 * 1024 * 1024)).toBe('1.5 MB') }) }) + + // Note: streamFileChunks tests require careful mocking of fs.createReadStream + // which is globally mocked in the test environment. These tests verify the + // streaming logic works correctly with mock streams. + describe('streamFileChunks', () => { + let mockSocket: Socket & EventEmitter + let mockProgress: ReturnType + + beforeEach(() => { + vi.clearAllMocks() + + mockSocket = Object.assign(new EventEmitter(), { + destroyed: false, + writable: true, + write: vi.fn().mockReturnValue(true), + cork: vi.fn(), + uncork: vi.fn() + }) as unknown as Socket & EventEmitter + + mockProgress = vi.fn() + }) + + afterEach(() => { + vi.resetAllMocks() + }) + + it('should throw when abort signal is already aborted', async () => { + const transfer = createTransferState('test-id', 'test.zip', 1024, 'checksum') + transfer.abortController.abort(new Error('Already cancelled')) + + await expect( + streamFileChunks(mockSocket, '/fake/path.zip', transfer, transfer.abortController.signal, mockProgress) + ).rejects.toThrow() + }) + + // Note: Full integration testing of streamFileChunks with actual file streaming + // requires a real file system, which cannot be easily mocked in ESM. + // The abort signal test above verifies the early abort path. + // Additional streaming tests are covered through integration tests. + }) }) diff --git a/src/main/services/lanTransfer/binaryProtocol.ts b/src/main/services/lanTransfer/binaryProtocol.ts index fc1f57a0b..864a8b95b 100644 --- a/src/main/services/lanTransfer/binaryProtocol.ts +++ b/src/main/services/lanTransfer/binaryProtocol.ts @@ -1,12 +1,12 @@ import type { Socket } from 'node:net' /** - * Binary protocol constants (v3) + * Binary protocol constants (v1) */ export const BINARY_TYPE_FILE_CHUNK = 0x01 /** - * Send file chunk as binary frame (protocol v3 - streaming mode) + * Send file chunk as binary frame (protocol v1 - streaming mode) * * Frame format: * ``` diff --git a/src/main/services/lanTransfer/handlers/fileTransfer.ts b/src/main/services/lanTransfer/handlers/fileTransfer.ts index 86ea61e13..c469a5842 100644 --- a/src/main/services/lanTransfer/handlers/fileTransfer.ts +++ b/src/main/services/lanTransfer/handlers/fileTransfer.ts @@ -175,7 +175,7 @@ export function sendFileEnd(ctx: FileTransferContext, transferId: string): void } /** - * Stream file chunks to the receiver (v3 streaming mode - no per-chunk acknowledgment). + * Stream file chunks to the receiver (v1 streaming mode - no per-chunk acknowledgment). */ export async function streamFileChunks( socket: Socket, @@ -201,7 +201,7 @@ export async function streamFileChunks( const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) bytesSent += buffer.length - // Send chunk as binary frame (v3 streaming) with backpressure handling + // Send chunk as binary frame (v1 streaming) with backpressure handling const canContinue = sendBinaryChunk(socket, transferId, chunkIndex, buffer) if (!canContinue) { await waitForSocketDrain(socket, abortSignal) diff --git a/src/main/services/lanTransfer/index.ts b/src/main/services/lanTransfer/index.ts index ad6cd2862..12f3c38af 100644 --- a/src/main/services/lanTransfer/index.ts +++ b/src/main/services/lanTransfer/index.ts @@ -1,7 +1,7 @@ /** * LAN Transfer Client Module * - * Protocol: v3.0 (streaming mode) + * Protocol: v1.0 (streaming mode) * * Features: * - Binary frame format for file chunks (no base64 overhead) From e71182470130ed7c2b61a5b1a183c81706f6b423 Mon Sep 17 00:00:00 2001 From: eeee0717 Date: Thu, 18 Dec 2025 10:48:32 +0800 Subject: [PATCH 5/6] chore: remove qrcode dependency --- package.json | 2 - packages/shared/IpcChannel.ts | 7 - src/main/ipc.ts | 8 - src/main/services/WebSocketService.ts | 359 --------------------- src/preload/index.ts | 7 - src/renderer/src/i18n/locales/en-us.json | 14 +- src/renderer/src/i18n/locales/zh-cn.json | 14 +- src/renderer/src/i18n/locales/zh-tw.json | 14 +- src/renderer/src/i18n/translate/de-de.json | 12 +- src/renderer/src/i18n/translate/el-gr.json | 12 +- src/renderer/src/i18n/translate/es-es.json | 12 +- src/renderer/src/i18n/translate/fr-fr.json | 12 +- src/renderer/src/i18n/translate/ja-jp.json | 12 +- src/renderer/src/i18n/translate/pt-pt.json | 12 +- src/renderer/src/i18n/translate/ru-ru.json | 12 +- yarn.lock | 152 +-------- 16 files changed, 17 insertions(+), 644 deletions(-) delete mode 100644 src/main/services/WebSocketService.ts diff --git a/package.json b/package.json index bd5329ac5..f34dd4b27 100644 --- a/package.json +++ b/package.json @@ -98,10 +98,8 @@ "node-stream-zip": "^1.15.0", "officeparser": "^4.2.0", "os-proxy-config": "^1.1.2", - "qrcode.react": "^4.2.0", "selection-hook": "^1.0.12", "sharp": "^0.34.3", - "socket.io": "^4.8.1", "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.1", "tesseract.js": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch", diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index 1748c4156..0fc811d16 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -382,13 +382,6 @@ export enum IpcChannel { ClaudeCodePlugin_ReadContent = 'claudeCodePlugin:read-content', ClaudeCodePlugin_WriteContent = 'claudeCodePlugin:write-content', - // WebSocket - WebSocket_Start = 'webSocket:start', - WebSocket_Stop = 'webSocket:stop', - WebSocket_Status = 'webSocket:status', - WebSocket_SendFile = 'webSocket:send-file', - WebSocket_GetAllCandidates = 'webSocket:get-all-candidates', - // Local Transfer LocalTransfer_ListServices = 'local-transfer:list', LocalTransfer_StartScan = 'local-transfer:start-scan', diff --git a/src/main/ipc.ts b/src/main/ipc.ts index f59f32c3d..15380821c 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -76,7 +76,6 @@ import { import storeSyncService from './services/StoreSyncService' import { themeService } from './services/ThemeService' import VertexAIService from './services/VertexAIService' -import WebSocketService from './services/WebSocketService' import { setOpenLinkExternal } from './services/WebviewService' import { windowService } from './services/WindowService' import { calculateDirectorySize, getResourcePath } from './utils' @@ -1102,13 +1101,6 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { } }) - // WebSocket - ipcMain.handle(IpcChannel.WebSocket_Start, WebSocketService.start) - ipcMain.handle(IpcChannel.WebSocket_Stop, WebSocketService.stop) - ipcMain.handle(IpcChannel.WebSocket_Status, WebSocketService.getStatus) - ipcMain.handle(IpcChannel.WebSocket_SendFile, WebSocketService.sendFile) - ipcMain.handle(IpcChannel.WebSocket_GetAllCandidates, WebSocketService.getAllCandidates) - ipcMain.handle(IpcChannel.LocalTransfer_ListServices, () => localTransferService.getState()) ipcMain.handle(IpcChannel.LocalTransfer_StartScan, () => localTransferService.startDiscovery({ resetList: true })) ipcMain.handle(IpcChannel.LocalTransfer_StopScan, () => localTransferService.stopDiscovery()) diff --git a/src/main/services/WebSocketService.ts b/src/main/services/WebSocketService.ts deleted file mode 100644 index e52919e96..000000000 --- a/src/main/services/WebSocketService.ts +++ /dev/null @@ -1,359 +0,0 @@ -import { loggerService } from '@logger' -import type { WebSocketCandidatesResponse, WebSocketStatusResponse } from '@shared/config/types' -import * as fs from 'fs' -import { networkInterfaces } from 'os' -import * as path from 'path' -import type { Socket } from 'socket.io' -import { Server } from 'socket.io' - -import { windowService } from './WindowService' - -const logger = loggerService.withContext('WebSocketService') - -class WebSocketService { - private io: Server | null = null - private isStarted = false - private port = 7017 - private connectedClients = new Set() - - private getLocalIpAddress(): string | undefined { - const interfaces = networkInterfaces() - - // 按优先级排序的网络接口名称模式 - const interfacePriority = [ - // macOS: 以太网/Wi-Fi 优先 - /^en[0-9]+$/, // en0, en1 (以太网/Wi-Fi) - /^(en|eth)[0-9]+$/, // 以太网接口 - /^wlan[0-9]+$/, // 无线接口 - // Windows: 以太网/Wi-Fi 优先 - /^(Ethernet|Wi-Fi|Local Area Connection)/, - /^(Wi-Fi|无线网络连接)/, - // Linux: 以太网/Wi-Fi 优先 - /^(eth|enp|wlp|wlan)[0-9]+/, - // 虚拟化接口(低优先级) - /^bridge[0-9]+$/, // Docker bridge - /^veth[0-9]+$/, // Docker veth - /^docker[0-9]+/, // Docker interfaces - /^br-[0-9a-f]+/, // Docker bridge - /^vmnet[0-9]+$/, // VMware - /^vboxnet[0-9]+$/, // VirtualBox - // VPN 隧道接口(低优先级) - /^utun[0-9]+$/, // macOS VPN - /^tun[0-9]+$/, // Linux/Unix VPN - /^tap[0-9]+$/, // TAP interfaces - /^tailscale[0-9]*$/, // Tailscale VPN - /^wg[0-9]+$/ // WireGuard VPN - ] - - const candidates: Array<{ interface: string; address: string; priority: number }> = [] - - for (const [name, ifaces] of Object.entries(interfaces)) { - for (const iface of ifaces || []) { - if (iface.family === 'IPv4' && !iface.internal) { - // 计算接口优先级 - let priority = 999 // 默认最低优先级 - for (let i = 0; i < interfacePriority.length; i++) { - if (interfacePriority[i].test(name)) { - priority = i - break - } - } - - candidates.push({ - interface: name, - address: iface.address, - priority - }) - } - } - } - - if (candidates.length === 0) { - logger.warn('无法获取局域网 IP,使用默认 IP: 127.0.0.1') - return '127.0.0.1' - } - - // 按优先级排序,选择优先级最高的 - candidates.sort((a, b) => a.priority - b.priority) - const best = candidates[0] - - logger.info(`获取局域网 IP: ${best.address} (interface: ${best.interface})`) - return best.address - } - - public start = async (): Promise<{ success: boolean; port?: number; error?: string }> => { - if (this.isStarted && this.io) { - return { success: true, port: this.port } - } - - try { - this.io = new Server(this.port, { - cors: { - origin: '*', - methods: ['GET', 'POST'] - }, - transports: ['websocket', 'polling'], - allowEIO3: true, - pingTimeout: 60000, - pingInterval: 25000 - }) - - this.io.on('connection', (socket: Socket) => { - this.connectedClients.add(socket.id) - - const mainWindow = windowService.getMainWindow() - if (!mainWindow) { - logger.error('Main window is null, cannot send connection event') - } else { - mainWindow.webContents.send('websocket-client-connected', { - connected: true, - clientId: socket.id - }) - logger.info(`Connection event sent to renderer, total clients: ${this.connectedClients.size}`) - } - - socket.on('message', (data) => { - logger.info('Received message from mobile:', data) - mainWindow?.webContents.send('websocket-message-received', data) - socket.emit('message_received', { success: true }) - }) - - socket.on('disconnect', () => { - logger.info(`Client disconnected: ${socket.id}`) - this.connectedClients.delete(socket.id) - - if (this.connectedClients.size === 0) { - mainWindow?.webContents.send('websocket-client-connected', { - connected: false, - clientId: socket.id - }) - } - }) - }) - - // Engine 层面的事件监听 - this.io.engine.on('connection_error', (err) => { - logger.error('Engine connection error:', err) - }) - - this.io.engine.on('connection', (rawSocket) => { - const remoteAddr = rawSocket.request.connection.remoteAddress - logger.info(`[Engine] Raw connection from: ${remoteAddr}`) - logger.info(`[Engine] Transport: ${rawSocket.transport.name}`) - - rawSocket.on('packet', (packet: { type: string; data?: any }) => { - logger.info( - `[Engine] ← Packet from ${remoteAddr}: type="${packet.type}"`, - packet.data ? { data: packet.data } : {} - ) - }) - - rawSocket.on('packetCreate', (packet: { type: string; data?: any }) => { - logger.info(`[Engine] → Packet to ${remoteAddr}: type="${packet.type}"`) - }) - - rawSocket.on('close', (reason: string) => { - logger.warn(`[Engine] Connection closed from ${remoteAddr}, reason: ${reason}`) - }) - - rawSocket.on('error', (error: Error) => { - logger.error(`[Engine] Connection error from ${remoteAddr}:`, error) - }) - }) - - // Socket.IO 握手失败监听 - this.io.on('connection_error', (err) => { - logger.error('[Socket.IO] Connection error during handshake:', err) - }) - - this.isStarted = true - logger.info(`WebSocket server started on port ${this.port}`) - - return { success: true, port: this.port } - } catch (error) { - logger.error('Failed to start WebSocket server:', error as Error) - return { - success: false, - error: error instanceof Error ? error.message : 'Unknown error' - } - } - } - - public stop = async (): Promise<{ success: boolean }> => { - if (!this.isStarted || !this.io) { - return { success: true } - } - - try { - await new Promise((resolve) => { - this.io!.close(() => { - resolve() - }) - }) - - this.io = null - this.isStarted = false - this.connectedClients.clear() - logger.info('WebSocket server stopped') - - return { success: true } - } catch (error) { - logger.error('Failed to stop WebSocket server:', error as Error) - return { success: false } - } - } - - public getStatus = async (): Promise => { - return { - isRunning: this.isStarted, - port: this.isStarted ? this.port : undefined, - ip: this.isStarted ? this.getLocalIpAddress() : undefined, - clientConnected: this.connectedClients.size > 0 - } - } - - public getAllCandidates = async (): Promise => { - const interfaces = networkInterfaces() - - // 按优先级排序的网络接口名称模式 - const interfacePriority = [ - // macOS: 以太网/Wi-Fi 优先 - /^en[0-9]+$/, // en0, en1 (以太网/Wi-Fi) - /^(en|eth)[0-9]+$/, // 以太网接口 - /^wlan[0-9]+$/, // 无线接口 - // Windows: 以太网/Wi-Fi 优先 - /^(Ethernet|Wi-Fi|Local Area Connection)/, - /^(Wi-Fi|无线网络连接)/, - // Linux: 以太网/Wi-Fi 优先 - /^(eth|enp|wlp|wlan)[0-9]+/, - // 虚拟化接口(低优先级) - /^bridge[0-9]+$/, // Docker bridge - /^veth[0-9]+$/, // Docker veth - /^docker[0-9]+/, // Docker interfaces - /^br-[0-9a-f]+/, // Docker bridge - /^vmnet[0-9]+$/, // VMware - /^vboxnet[0-9]+$/, // VirtualBox - // VPN 隧道接口(低优先级) - /^utun[0-9]+$/, // macOS VPN - /^tun[0-9]+$/, // Linux/Unix VPN - /^tap[0-9]+$/, // TAP interfaces - /^tailscale[0-9]*$/, // Tailscale VPN - /^wg[0-9]+$/ // WireGuard VPN - ] - - const candidates: Array<{ host: string; interface: string; priority: number }> = [] - - for (const [name, ifaces] of Object.entries(interfaces)) { - for (const iface of ifaces || []) { - if (iface.family === 'IPv4' && !iface.internal) { - // 计算接口优先级 - let priority = 999 // 默认最低优先级 - for (let i = 0; i < interfacePriority.length; i++) { - if (interfacePriority[i].test(name)) { - priority = i - break - } - } - - candidates.push({ - host: iface.address, - interface: name, - priority - }) - - logger.debug(`Found interface: ${name} -> ${iface.address} (priority: ${priority})`) - } - } - } - - // 按优先级排序返回 - candidates.sort((a, b) => a.priority - b.priority) - logger.info( - `Found ${candidates.length} IP candidates: ${candidates.map((c) => `${c.host}(${c.interface})`).join(', ')}` - ) - return candidates - } - - public sendFile = async ( - _: Electron.IpcMainInvokeEvent, - filePath: string - ): Promise<{ success: boolean; error?: string }> => { - if (!this.isStarted || !this.io) { - const errorMsg = 'WebSocket server is not running.' - logger.error(errorMsg) - return { success: false, error: errorMsg } - } - - if (this.connectedClients.size === 0) { - const errorMsg = 'No client connected.' - logger.error(errorMsg) - return { success: false, error: errorMsg } - } - - const mainWindow = windowService.getMainWindow() - - return new Promise((resolve, reject) => { - const stats = fs.statSync(filePath) - const totalSize = stats.size - const filename = path.basename(filePath) - const stream = fs.createReadStream(filePath) - let bytesSent = 0 - const startTime = Date.now() - - logger.info(`Starting file transfer: ${filename} (${this.formatFileSize(totalSize)})`) - - // 向客户端发送文件开始的信号,包含文件名和总大小 - this.io!.emit('zip-file-start', { filename, totalSize }) - - stream.on('data', (chunk) => { - bytesSent += chunk.length - const progress = (bytesSent / totalSize) * 100 - - // 向客户端发送文件块 - this.io!.emit('zip-file-chunk', chunk) - - // 向渲染进程发送进度更新 - mainWindow?.webContents.send('file-send-progress', { progress }) - - // 每10%记录一次进度 - if (Math.floor(progress) % 10 === 0) { - const elapsed = (Date.now() - startTime) / 1000 - const speed = elapsed > 0 ? bytesSent / elapsed : 0 - logger.info(`Transfer progress: ${Math.floor(progress)}% (${this.formatFileSize(speed)}/s)`) - } - }) - - stream.on('end', () => { - const totalTime = (Date.now() - startTime) / 1000 - const avgSpeed = totalTime > 0 ? totalSize / totalTime : 0 - logger.info( - `File transfer completed: ${filename} in ${totalTime.toFixed(1)}s (${this.formatFileSize(avgSpeed)}/s)` - ) - - // 确保发送100%的进度 - mainWindow?.webContents.send('file-send-progress', { progress: 100 }) - // 向客户端发送文件结束的信号 - this.io!.emit('zip-file-end') - resolve({ success: true }) - }) - - stream.on('error', (error) => { - logger.error(`File transfer failed: ${filename}`, error) - reject({ - success: false, - error: error instanceof Error ? error.message : 'Unknown error' - }) - }) - }) - } - - private formatFileSize(bytes: number): string { - if (bytes === 0) return '0 B' - const k = 1024 - const sizes = ['B', 'KB', 'MB', 'GB'] - const i = Math.floor(Math.log(bytes) / Math.log(k)) - return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] - } -} - -export default new WebSocketService() diff --git a/src/preload/index.ts b/src/preload/index.ts index eb0e80650..aa07d832e 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -626,13 +626,6 @@ const api = { sendFile: (filePath: string): Promise => ipcRenderer.invoke(IpcChannel.LocalTransfer_SendFile, { filePath }), cancelTransfer: (): Promise => ipcRenderer.invoke(IpcChannel.LocalTransfer_CancelTransfer) - }, - webSocket: { - start: () => ipcRenderer.invoke(IpcChannel.WebSocket_Start), - stop: () => ipcRenderer.invoke(IpcChannel.WebSocket_Stop), - status: () => ipcRenderer.invoke(IpcChannel.WebSocket_Status), - sendFile: (filePath: string) => ipcRenderer.invoke(IpcChannel.WebSocket_SendFile, filePath), - getAllCandidates: () => ipcRenderer.invoke(IpcChannel.WebSocket_GetAllCandidates) } } diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 1747343d5..8fb277948 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -3218,12 +3218,9 @@ }, "content": "Export some data, including chat logs and settings. Please note that the backup process may take some time. Thank you for your patience.", "lan": { - "auto_close_tip": "Auto-closing in {{seconds}} seconds...", - "confirm_close_message": "File transfer is in progress. Closing will interrupt the transfer. Are you sure you want to force close?", - "confirm_close_title": "Confirm Close", "connected": "Connected", "connection_failed": "Connection failed", - "content": "Please ensure your computer and phone are on the same network for LAN transfer. Open the Cherry Studio App to scan this QR code.", + "content": "Please ensure your computer and phone are on the same network for LAN transfer.", "device_list_title": "Local network devices", "discovered_devices": "Discovered devices", "error": { @@ -3241,8 +3238,6 @@ "progress": "Sending... {{progress}}%", "success": "File sent successfully" }, - "force_close": "Force Close", - "generating_qr": "Generating QR code...", "handshake": { "button": "Handshake", "failed": "Handshake failed: {{message}}", @@ -3255,14 +3250,10 @@ "ip_addresses": "IP addresses", "last_seen": "Last seen at {{time}}", "metadata": "Metadata", - "noZipSelected": "No compressed file selected", "no_connection_warning": "Please open LAN Transfer on Cherry Studio mobile", "no_devices": "No LAN peers found yet", "scan_devices": "Scan devices", - "scan_qr": "Please scan QR code with your phone", "scanning_hint": "Scanning your local network for Cherry Studio peers...", - "selectZip": "Select a compressed file", - "sendZip": "Begin data recovery", "send_file": "Send File", "status": { "completed": "Transfer completed", @@ -3272,8 +3263,7 @@ "error": "Connection error", "initializing": "Initializing connection...", "preparing": "Preparing transfer...", - "sending": "Transferring {{progress}}%", - "waiting_qr_scan": "Please scan QR code to connect" + "sending": "Transferring {{progress}}%" }, "status_badge_idle": "Idle", "status_badge_scanning": "Scanning", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 9813ffd76..0eb0139c1 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -3218,12 +3218,9 @@ }, "content": "导出部分数据,包括聊天记录、设置。请注意,备份过程可能需要一些时间,感谢您的耐心等待。", "lan": { - "auto_close_tip": "{{seconds}} 秒后自动关闭...", - "confirm_close_message": "文件正在传输中,关闭将中断传输。确定要强制关闭吗?", - "confirm_close_title": "确认关闭", "connected": "连接成功", "connection_failed": "连接失败", - "content": "请确保电脑和手机处于同一网络以使用局域网传输。请打开 Cherry Studio App 扫描此二维码。", + "content": "请确保电脑和手机处于同一网络以使用局域网传输。", "device_list_title": "局域网设备列表", "discovered_devices": "已发现的设备", "error": { @@ -3241,8 +3238,6 @@ "progress": "发送中... {{progress}}%", "success": "文件发送成功" }, - "force_close": "强制关闭", - "generating_qr": "正在生成二维码...", "handshake": { "button": "握手测试", "failed": "握手失败:{{message}}", @@ -3255,14 +3250,10 @@ "ip_addresses": "IP 地址", "last_seen": "最后活动:{{time}}", "metadata": "元数据", - "noZipSelected": "未选择压缩文件", "no_connection_warning": "请在 Cherry Studio 移动端打开局域网传输", "no_devices": "尚未发现局域网设备", "scan_devices": "扫描设备", - "scan_qr": "请使用手机扫码连接", "scanning_hint": "正在扫描局域网中的 Cherry Studio 设备...", - "selectZip": "选择压缩文件", - "sendZip": "开始恢复数据", "send_file": "发送文件", "status": { "completed": "传输完成", @@ -3272,8 +3263,7 @@ "error": "连接出错", "initializing": "正在初始化连接...", "preparing": "准备传输中...", - "sending": "传输中 {{progress}}%", - "waiting_qr_scan": "请扫描二维码连接" + "sending": "传输中 {{progress}}%" }, "status_badge_idle": "空闲", "status_badge_scanning": "扫描中", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index edf58024a..c3a9d0af2 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -3218,12 +3218,9 @@ }, "content": "匯出部分資料,包括聊天記錄與設定。請注意,備份過程可能需要一些時間,感謝耐心等候。", "lan": { - "auto_close_tip": "將於 {{seconds}} 秒後自動關閉...", - "confirm_close_message": "檔案傳輸正在進行中。關閉將會中斷傳輸。您確定要強制關閉嗎?", - "confirm_close_title": "確認關閉", "connected": "已連線", "connection_failed": "連線失敗", - "content": "請確保電腦和手機處於同一網路以使用區域網路傳輸。請開啟 Cherry Studio App 掃描此 QR 碼。", + "content": "請確保電腦和手機處於同一網路以使用區域網路傳輸。", "device_list_title": "區域網路裝置", "discovered_devices": "已發現的裝置", "error": { @@ -3241,8 +3238,6 @@ "progress": "傳送中... {{progress}}%", "success": "檔案傳送成功" }, - "force_close": "強制關閉", - "generating_qr": "正在產生 QR 碼...", "handshake": { "button": "握手", "failed": "握手失敗:{{message}}", @@ -3255,14 +3250,10 @@ "ip_addresses": "IP 位址", "last_seen": "上次看到:{{time}}", "metadata": "中繼資料", - "noZipSelected": "未選取壓縮檔案", "no_connection_warning": "請在 Cherry Studio 行動裝置開啟區域網路傳輸", "no_devices": "尚未找到區域網路節點", "scan_devices": "掃描裝置", - "scan_qr": "請使用手機掃描 QR 碼", "scanning_hint": "正在掃描區域網路中的 Cherry Studio 裝置...", - "selectZip": "選擇壓縮檔案", - "sendZip": "開始還原資料", "send_file": "傳送檔案", "status": { "completed": "傳輸完成", @@ -3272,8 +3263,7 @@ "error": "連線錯誤", "initializing": "正在初始化連線...", "preparing": "正在準備傳輸...", - "sending": "傳輸中 {{progress}}%", - "waiting_qr_scan": "請掃描 QR 碼以連線" + "sending": "傳輸中 {{progress}}%" }, "status_badge_idle": "閒置", "status_badge_scanning": "掃描中", diff --git a/src/renderer/src/i18n/translate/de-de.json b/src/renderer/src/i18n/translate/de-de.json index e07649670..97f6e8462 100644 --- a/src/renderer/src/i18n/translate/de-de.json +++ b/src/renderer/src/i18n/translate/de-de.json @@ -3218,9 +3218,6 @@ }, "content": "Exportieren Sie einige Daten, einschließlich Chat-Protokollen und Einstellungen. Bitte beachten Sie, dass der Sicherungsvorgang einige Zeit in Anspruch nehmen kann. Vielen Dank für Ihre Geduld.", "lan": { - "auto_close_tip": "Automatisches Schließen in {{seconds}} Sekunden...", - "confirm_close_message": "Dateiübertragung läuft. Beim Schließen wird die Übertragung unterbrochen. Möchten Sie wirklich das Schließen erzwingen?", - "confirm_close_title": "Schließen bestätigen", "connected": "Verbunden", "connection_failed": "Verbindung fehlgeschlagen", "content": "Bitte stelle sicher, dass sich dein Computer und dein Telefon im selben Netzwerk befinden, um eine LAN-Übertragung durchzuführen. Öffne die Cherry Studio App, um diesen QR-Code zu scannen.", @@ -3241,8 +3238,6 @@ "progress": "[to be translated]:Sending... {{progress}}%", "success": "[to be translated]:File sent successfully" }, - "force_close": "Erzwungenes Schließen", - "generating_qr": "QR-Code wird generiert...", "handshake": { "button": "[to be translated]:Handshake", "failed": "[to be translated]:Handshake failed: {{message}}", @@ -3255,14 +3250,10 @@ "ip_addresses": "[to be translated]:IP addresses", "last_seen": "[to be translated]:Last seen at {{time}}", "metadata": "[to be translated]:Metadata", - "noZipSelected": "Keine komprimierte Datei ausgewählt", "no_connection_warning": "[to be translated]:Please open LAN Transfer on Cherry Studio mobile", "no_devices": "[to be translated]:No LAN peers found yet", "scan_devices": "[to be translated]:Scan devices", - "scan_qr": "Bitte scannen Sie den QR-Code mit Ihrem Telefon.", "scanning_hint": "[to be translated]:Scanning your local network for Cherry Studio peers...", - "selectZip": "Wählen Sie eine komprimierte Datei", - "sendZip": "Datenwiederherstellung beginnen", "send_file": "[to be translated]:Send File", "status": { "completed": "Übertragung abgeschlossen", @@ -3272,8 +3263,7 @@ "error": "Verbindungsfehler", "initializing": "Verbindung wird initialisiert...", "preparing": "Übertragung wird vorbereitet...", - "sending": "Übertrage {{progress}}%", - "waiting_qr_scan": "Bitte QR-Code scannen, um zu verbinden" + "sending": "Übertrage {{progress}}%" }, "status_badge_idle": "[to be translated]:Idle", "status_badge_scanning": "[to be translated]:Scanning", diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index 373546fb6..ba2ff6287 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -3218,9 +3218,6 @@ }, "content": "Εξαγωγή μέρους των δεδομένων, συμπεριλαμβανομένων των ιστορικών συνομιλιών και των ρυθμίσεων. Σημειώστε ότι η διαδικασία δημιουργίας αντιγράφων ασφαλείας ενδέχεται να διαρκέσει κάποιο χρονικό διάστημα, ευχαριστούμε για την υπομονή σας.", "lan": { - "auto_close_tip": "Αυτόματο κλείσιμο σε {{seconds}} δευτερόλεπτα...", - "confirm_close_message": "Η μεταφορά αρχείων είναι σε εξέλιξη. Το κλείσιμο θα διακόψει τη μεταφορά. Είστε σίγουροι ότι θέλετε να κλείσετε βίαια;", - "confirm_close_title": "Επιβεβαίωση Κλεισίματος", "connected": "Συνδεδεμένος", "connection_failed": "Η σύνδεση απέτυχε", "content": "Βεβαιωθείτε ότι ο υπολογιστής και το κινητό βρίσκονται στο ίδιο δίκτυο για να χρησιμοποιήσετε τη μεταφορά LAN. Ανοίξτε την εφαρμογή Cherry Studio και σαρώστε αυτόν τον κωδικό QR.", @@ -3241,8 +3238,6 @@ "progress": "[to be translated]:Sending... {{progress}}%", "success": "[to be translated]:File sent successfully" }, - "force_close": "Κλείσιμο με βία", - "generating_qr": "Δημιουργία κώδικα QR...", "handshake": { "button": "[to be translated]:Handshake", "failed": "[to be translated]:Handshake failed: {{message}}", @@ -3255,14 +3250,10 @@ "ip_addresses": "[to be translated]:IP addresses", "last_seen": "[to be translated]:Last seen at {{time}}", "metadata": "[to be translated]:Metadata", - "noZipSelected": "Δεν επιλέχθηκε συμπιεσμένο αρχείο", "no_connection_warning": "[to be translated]:Please open LAN Transfer on Cherry Studio mobile", "no_devices": "[to be translated]:No LAN peers found yet", "scan_devices": "[to be translated]:Scan devices", - "scan_qr": "Παρακαλώ σαρώστε τον κωδικό QR με το τηλέφωνό σας", "scanning_hint": "[to be translated]:Scanning your local network for Cherry Studio peers...", - "selectZip": "Επιλέξτε συμπιεσμένο αρχείο", - "sendZip": "Έναρξη ανάκτησης δεδομένων", "send_file": "[to be translated]:Send File", "status": { "completed": "Η μεταφορά ολοκληρώθηκε", @@ -3272,8 +3263,7 @@ "error": "Σφάλμα σύνδεσης", "initializing": "Αρχικοποίηση σύνδεσης...", "preparing": "Προετοιμασία μεταφοράς...", - "sending": "Μεταφορά {{progress}}%", - "waiting_qr_scan": "Παρακαλώ σαρώστε τον κωδικό QR για σύνδεση" + "sending": "Μεταφορά {{progress}}%" }, "status_badge_idle": "[to be translated]:Idle", "status_badge_scanning": "[to be translated]:Scanning", diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index 34a1dd4dc..b3728b0fa 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -3218,9 +3218,6 @@ }, "content": "Exportar parte de los datos, incluidos los registros de chat y la configuración. Tenga en cuenta que el proceso de copia de seguridad puede tardar un tiempo; gracias por su paciencia.", "lan": { - "auto_close_tip": "Cierre automático en {{seconds}} segundos...", - "confirm_close_message": "La transferencia de archivos está en progreso. Cerrar interrumpirá la transferencia. ¿Estás seguro de que quieres forzar el cierre?", - "confirm_close_title": "Confirmar Cierre", "connected": "Conectado", "connection_failed": "Conexión fallida", "content": "Asegúrate de que el ordenador y el móvil estén en la misma red para usar la transferencia por LAN. Abre la aplicación Cherry Studio y escanea este código QR.", @@ -3241,8 +3238,6 @@ "progress": "[to be translated]:Sending... {{progress}}%", "success": "[to be translated]:File sent successfully" }, - "force_close": "Cerrar forzosamente", - "generating_qr": "Generando código QR...", "handshake": { "button": "[to be translated]:Handshake", "failed": "[to be translated]:Handshake failed: {{message}}", @@ -3255,14 +3250,10 @@ "ip_addresses": "[to be translated]:IP addresses", "last_seen": "[to be translated]:Last seen at {{time}}", "metadata": "[to be translated]:Metadata", - "noZipSelected": "No se ha seleccionado ningún archivo comprimido", "no_connection_warning": "[to be translated]:Please open LAN Transfer on Cherry Studio mobile", "no_devices": "[to be translated]:No LAN peers found yet", "scan_devices": "[to be translated]:Scan devices", - "scan_qr": "Por favor, escanea el código QR con tu teléfono", "scanning_hint": "[to be translated]:Scanning your local network for Cherry Studio peers...", - "selectZip": "Seleccionar archivo comprimido", - "sendZip": "Comenzar la recuperación de datos", "send_file": "[to be translated]:Send File", "status": { "completed": "Transferencia completada", @@ -3272,8 +3263,7 @@ "error": "Error de conexión", "initializing": "Inicializando conexión...", "preparing": "Preparando transferencia...", - "sending": "Transfiriendo {{progress}}%", - "waiting_qr_scan": "Por favor, escanea el código QR para conectarte" + "sending": "Transfiriendo {{progress}}%" }, "status_badge_idle": "[to be translated]:Idle", "status_badge_scanning": "[to be translated]:Scanning", diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index 5c5bd6f64..8427f4453 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -3218,9 +3218,6 @@ }, "content": "Exporter une partie des données, incluant les historiques de discussion et les paramètres. Veuillez noter que le processus de sauvegarde peut prendre un certain temps ; merci pour votre patience.", "lan": { - "auto_close_tip": "Fermeture automatique dans {{seconds}} secondes...", - "confirm_close_message": "Le transfert de fichier est en cours. Fermer interrompra le transfert. Êtes-vous sûr de vouloir forcer la fermeture ?", - "confirm_close_title": "Confirmer la fermeture", "connected": "Connecté", "connection_failed": "Échec de la connexion", "content": "Assurez-vous que l'ordinateur et le téléphone sont connectés au même réseau pour utiliser le transfert en réseau local. Ouvrez l'application Cherry Studio et scannez ce code QR.", @@ -3241,8 +3238,6 @@ "progress": "[to be translated]:Sending... {{progress}}%", "success": "[to be translated]:File sent successfully" }, - "force_close": "Fermer de force", - "generating_qr": "Génération du code QR...", "handshake": { "button": "[to be translated]:Handshake", "failed": "[to be translated]:Handshake failed: {{message}}", @@ -3255,14 +3250,10 @@ "ip_addresses": "[to be translated]:IP addresses", "last_seen": "[to be translated]:Last seen at {{time}}", "metadata": "[to be translated]:Metadata", - "noZipSelected": "Aucun fichier compressé sélectionné", "no_connection_warning": "[to be translated]:Please open LAN Transfer on Cherry Studio mobile", "no_devices": "[to be translated]:No LAN peers found yet", "scan_devices": "[to be translated]:Scan devices", - "scan_qr": "Veuillez scanner le code QR avec votre téléphone", "scanning_hint": "[to be translated]:Scanning your local network for Cherry Studio peers...", - "selectZip": "Sélectionner le fichier compressé", - "sendZip": "Commencer la restauration des données", "send_file": "[to be translated]:Send File", "status": { "completed": "Transfert terminé", @@ -3272,8 +3263,7 @@ "error": "Erreur de connexion", "initializing": "Initialisation de la connexion...", "preparing": "Préparation du transfert...", - "sending": "Transfert {{progress}} %", - "waiting_qr_scan": "Veuillez scanner le code QR pour vous connecter" + "sending": "Transfert {{progress}} %" }, "status_badge_idle": "[to be translated]:Idle", "status_badge_scanning": "[to be translated]:Scanning", diff --git a/src/renderer/src/i18n/translate/ja-jp.json b/src/renderer/src/i18n/translate/ja-jp.json index 5c6e07d68..9fbb53031 100644 --- a/src/renderer/src/i18n/translate/ja-jp.json +++ b/src/renderer/src/i18n/translate/ja-jp.json @@ -3218,9 +3218,6 @@ }, "content": "一部のデータ、チャット履歴や設定をエクスポートします。バックアップには時間がかかる場合がありますので、しばらくお待ちください。", "lan": { - "auto_close_tip": "{{seconds}}秒後に自動的に閉じます...", - "confirm_close_message": "ファイル転送が進行中です。閉じると転送が中断されます。強制終了してもよろしいですか?", - "confirm_close_title": "閉じることを確認", "connected": "接続済み", "connection_failed": "接続に失敗しました", "content": "コンピューターとスマートフォンが同じネットワークに接続されていることを確認し、ローカルエリアネットワーク転送を使用してください。Cherry Studioアプリを開き、このQRコードをスキャンしてください。", @@ -3241,8 +3238,6 @@ "progress": "[to be translated]:Sending... {{progress}}%", "success": "[to be translated]:File sent successfully" }, - "force_close": "強制終了", - "generating_qr": "QRコードを生成中...", "handshake": { "button": "[to be translated]:Handshake", "failed": "[to be translated]:Handshake failed: {{message}}", @@ -3255,14 +3250,10 @@ "ip_addresses": "[to be translated]:IP addresses", "last_seen": "[to be translated]:Last seen at {{time}}", "metadata": "[to be translated]:Metadata", - "noZipSelected": "圧縮ファイルが選択されていません", "no_connection_warning": "[to be translated]:Please open LAN Transfer on Cherry Studio mobile", "no_devices": "[to be translated]:No LAN peers found yet", "scan_devices": "[to be translated]:Scan devices", - "scan_qr": "携帯電話でQRコードをスキャンしてください", "scanning_hint": "[to be translated]:Scanning your local network for Cherry Studio peers...", - "selectZip": "圧縮ファイルを選択", - "sendZip": "データの復元を開始します", "send_file": "[to be translated]:Send File", "status": { "completed": "転送完了", @@ -3272,8 +3263,7 @@ "error": "接続エラー", "initializing": "接続を初期化中...", "preparing": "転送準備中...", - "sending": "転送中 {{progress}}%", - "waiting_qr_scan": "QRコードをスキャンして接続してください" + "sending": "転送中 {{progress}}%" }, "status_badge_idle": "[to be translated]:Idle", "status_badge_scanning": "[to be translated]:Scanning", diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index 74fb72fff..b7efbd17e 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -3218,9 +3218,6 @@ }, "content": "Exportar parte dos dados, incluindo registros de conversas e configurações. Observe que o processo de backup pode demorar um pouco; agradecemos sua paciência.", "lan": { - "auto_close_tip": "Fechando automaticamente em {{seconds}} segundos...", - "confirm_close_message": "Transferência de arquivo em andamento. Fechar irá interromper a transferência. Tem certeza de que deseja forçar o fechamento?", - "confirm_close_title": "Confirmar Fechamento", "connected": "Conectado", "connection_failed": "Falha na conexão", "content": "Certifique-se de que o computador e o telefone estejam na mesma rede para usar a transferência via LAN. Abra o aplicativo Cherry Studio e escaneie este código QR.", @@ -3241,8 +3238,6 @@ "progress": "[to be translated]:Sending... {{progress}}%", "success": "[to be translated]:File sent successfully" }, - "force_close": "Forçar Fechamento", - "generating_qr": "Gerando código QR...", "handshake": { "button": "[to be translated]:Handshake", "failed": "[to be translated]:Handshake failed: {{message}}", @@ -3255,14 +3250,10 @@ "ip_addresses": "[to be translated]:IP addresses", "last_seen": "[to be translated]:Last seen at {{time}}", "metadata": "[to be translated]:Metadata", - "noZipSelected": "Nenhum arquivo de compressão selecionado", "no_connection_warning": "[to be translated]:Please open LAN Transfer on Cherry Studio mobile", "no_devices": "[to be translated]:No LAN peers found yet", "scan_devices": "[to be translated]:Scan devices", - "scan_qr": "Por favor, escaneie o código QR com o seu telefone", "scanning_hint": "[to be translated]:Scanning your local network for Cherry Studio peers...", - "selectZip": "Selecionar arquivo compactado", - "sendZip": "Iniciar recuperação de dados", "send_file": "[to be translated]:Send File", "status": { "completed": "Transferência concluída", @@ -3272,8 +3263,7 @@ "error": "Erro de conexão", "initializing": "Inicializando conexão...", "preparing": "Preparando transferência...", - "sending": "Transferindo {{progress}}%", - "waiting_qr_scan": "Por favor, escaneie o código QR para conectar" + "sending": "Transferindo {{progress}}%" }, "status_badge_idle": "[to be translated]:Idle", "status_badge_scanning": "[to be translated]:Scanning", diff --git a/src/renderer/src/i18n/translate/ru-ru.json b/src/renderer/src/i18n/translate/ru-ru.json index fb0e6a3b6..71ab1617f 100644 --- a/src/renderer/src/i18n/translate/ru-ru.json +++ b/src/renderer/src/i18n/translate/ru-ru.json @@ -3218,9 +3218,6 @@ }, "content": "Экспорт части данных, включая историю чатов и настройки. Обратите внимание, процесс резервного копирования может занять некоторое время, благодарим за ваше терпение.", "lan": { - "auto_close_tip": "Автоматическое закрытие через {{seconds}} секунд...", - "confirm_close_message": "Передача файла в процессе. Закрытие прервет передачу. Вы уверены, что хотите принудительно закрыть?", - "confirm_close_title": "Подтвердить закрытие", "connected": "Подключено", "connection_failed": "Соединение не удалось", "content": "Убедитесь, что компьютер и телефон подключены к одной сети, чтобы использовать локальную передачу. Откройте приложение Cherry Studio и отсканируйте этот QR-код.", @@ -3241,8 +3238,6 @@ "progress": "[to be translated]:Sending... {{progress}}%", "success": "[to be translated]:File sent successfully" }, - "force_close": "Принудительное закрытие", - "generating_qr": "Генерация QR-кода...", "handshake": { "button": "[to be translated]:Handshake", "failed": "[to be translated]:Handshake failed: {{message}}", @@ -3255,14 +3250,10 @@ "ip_addresses": "[to be translated]:IP addresses", "last_seen": "[to be translated]:Last seen at {{time}}", "metadata": "[to be translated]:Metadata", - "noZipSelected": "Архив не выбран", "no_connection_warning": "[to be translated]:Please open LAN Transfer on Cherry Studio mobile", "no_devices": "[to be translated]:No LAN peers found yet", "scan_devices": "[to be translated]:Scan devices", - "scan_qr": "Пожалуйста, отсканируйте QR-код с помощью вашего телефона", "scanning_hint": "[to be translated]:Scanning your local network for Cherry Studio peers...", - "selectZip": "Выберите архив", - "sendZip": "Начать восстановление данных", "send_file": "[to be translated]:Send File", "status": { "completed": "Перевод завершён", @@ -3272,8 +3263,7 @@ "error": "Ошибка подключения", "initializing": "Инициализация соединения...", "preparing": "Подготовка передачи...", - "sending": "Передача {{progress}}%", - "waiting_qr_scan": "Пожалуйста, отсканируйте QR-код для подключения" + "sending": "Передача {{progress}}%" }, "status_badge_idle": "[to be translated]:Idle", "status_badge_scanning": "[to be translated]:Scanning", diff --git a/yarn.lock b/yarn.lock index 4a6dcea6d..787baa05a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7250,13 +7250,6 @@ __metadata: languageName: node linkType: hard -"@socket.io/component-emitter@npm:~3.1.0": - version: 3.1.2 - resolution: "@socket.io/component-emitter@npm:3.1.2" - checksum: 10c0/c4242bad66f67e6f7b712733d25b43cbb9e19a595c8701c3ad99cbeb5901555f78b095e24852f862fffb43e96f1d8552e62def885ca82ae1bb05da3668fd87d7 - languageName: node - linkType: hard - "@standard-schema/spec@npm:^1.0.0": version: 1.0.0 resolution: "@standard-schema/spec@npm:1.0.0" @@ -8274,7 +8267,7 @@ __metadata: languageName: node linkType: hard -"@types/cors@npm:^2.8.12, @types/cors@npm:^2.8.19": +"@types/cors@npm:^2.8.19": version: 2.8.19 resolution: "@types/cors@npm:2.8.19" dependencies: @@ -8831,15 +8824,6 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:>=10.0.0": - version: 24.3.1 - resolution: "@types/node@npm:24.3.1" - dependencies: - undici-types: "npm:~7.10.0" - checksum: 10c0/99b86fc32294fcd61136ca1f771026443a1e370e9f284f75e243b29299dd878e18c193deba1ce29a374932db4e30eb80826e1049b9aad02d36f5c30b94b6f928 - languageName: node - linkType: hard - "@types/node@npm:^18.11.18": version: 18.19.86 resolution: "@types/node@npm:18.19.86" @@ -10286,7 +10270,6 @@ __metadata: pdf-lib: "npm:^1.17.1" pdf-parse: "npm:^1.1.1" proxy-agent: "npm:^6.5.0" - qrcode.react: "npm:^4.2.0" react: "npm:^19.2.0" react-dom: "npm:^19.2.0" react-error-boundary: "npm:^6.0.0" @@ -10318,7 +10301,6 @@ __metadata: selection-hook: "npm:^1.0.12" sharp: "npm:^0.34.3" shiki: "npm:^3.12.0" - socket.io: "npm:^4.8.1" strict-url-sanitise: "npm:^0.0.1" string-width: "npm:^7.2.0" striptags: "npm:^3.2.0" @@ -10381,16 +10363,6 @@ __metadata: languageName: node linkType: hard -"accepts@npm:~1.3.4": - version: 1.3.8 - resolution: "accepts@npm:1.3.8" - dependencies: - mime-types: "npm:~2.1.34" - negotiator: "npm:0.6.3" - checksum: 10c0/3a35c5f5586cfb9a21163ca47a5f77ac34fa8ceb5d17d2fa2c0d81f41cbd7f8c6fa52c77e2c039acc0f4d09e71abdc51144246900f6bef5e3c4b333f77d89362 - languageName: node - linkType: hard - "acorn-jsx@npm:^5.3.2": version: 5.3.2 resolution: "acorn-jsx@npm:5.3.2" @@ -11029,13 +11001,6 @@ __metadata: languageName: node linkType: hard -"base64id@npm:2.0.0, base64id@npm:~2.0.0": - version: 2.0.0 - resolution: "base64id@npm:2.0.0" - checksum: 10c0/6919efd237ed44b9988cbfc33eca6f173a10e810ce50292b271a1a421aac7748ef232a64d1e6032b08f19aae48dce6ee8f66c5ae2c9e5066c82b884861d4d453 - languageName: node - linkType: hard - "basic-ftp@npm:^5.0.2": version: 5.0.5 resolution: "basic-ftp@npm:5.0.5" @@ -12272,7 +12237,7 @@ __metadata: languageName: node linkType: hard -"cookie@npm:^0.7.1, cookie@npm:~0.7.2": +"cookie@npm:^0.7.1": version: 0.7.2 resolution: "cookie@npm:0.7.2" checksum: 10c0/9596e8ccdbf1a3a88ae02cf5ee80c1c50959423e1022e4e60b91dd87c622af1da309253d8abdb258fb5e3eacb4f08e579dc58b4897b8087574eee0fd35dfa5d2 @@ -12309,7 +12274,7 @@ __metadata: languageName: node linkType: hard -"cors@npm:^2.8.5, cors@npm:~2.8.5": +"cors@npm:^2.8.5": version: 2.8.5 resolution: "cors@npm:2.8.5" dependencies: @@ -12979,18 +12944,6 @@ __metadata: languageName: node linkType: hard -"debug@npm:~4.3.1, debug@npm:~4.3.2, debug@npm:~4.3.4": - version: 4.3.7 - resolution: "debug@npm:4.3.7" - dependencies: - ms: "npm:^2.1.3" - peerDependenciesMeta: - supports-color: - optional: true - checksum: 10c0/1471db19c3b06d485a622d62f65947a19a23fbd0dd73f7fd3eafb697eec5360cde447fb075919987899b1a2096e85d35d4eb5a4de09a57600ac9cf7e6c8e768b - languageName: node - linkType: hard - "decamelize@npm:1.2.0": version: 1.2.0 resolution: "decamelize@npm:1.2.0" @@ -13956,30 +13909,6 @@ __metadata: languageName: node linkType: hard -"engine.io-parser@npm:~5.2.1": - version: 5.2.3 - resolution: "engine.io-parser@npm:5.2.3" - checksum: 10c0/ed4900d8dbef470ab3839ccf3bfa79ee518ea8277c7f1f2759e8c22a48f64e687ea5e474291394d0c94f84054749fd93f3ef0acb51fa2f5f234cc9d9d8e7c536 - languageName: node - linkType: hard - -"engine.io@npm:~6.6.0": - version: 6.6.4 - resolution: "engine.io@npm:6.6.4" - dependencies: - "@types/cors": "npm:^2.8.12" - "@types/node": "npm:>=10.0.0" - accepts: "npm:~1.3.4" - base64id: "npm:2.0.0" - cookie: "npm:~0.7.2" - cors: "npm:~2.8.5" - debug: "npm:~4.3.1" - engine.io-parser: "npm:~5.2.1" - ws: "npm:~8.17.1" - checksum: 10c0/845761163f8ea7962c049df653b75dafb6b3693ad6f59809d4474751d7b0392cbf3dc2730b8a902ff93677a91fd28711d34ab29efd348a8a4b49c6b0724021ab - languageName: node - linkType: hard - "enhanced-resolve@npm:^5.18.3": version: 5.18.3 resolution: "enhanced-resolve@npm:5.18.3" @@ -19158,7 +19087,7 @@ __metadata: languageName: node linkType: hard -"mime-types@npm:^2.1.12, mime-types@npm:^2.1.35, mime-types@npm:~2.1.34": +"mime-types@npm:^2.1.12, mime-types@npm:^2.1.35": version: 2.1.35 resolution: "mime-types@npm:2.1.35" dependencies: @@ -19657,13 +19586,6 @@ __metadata: languageName: node linkType: hard -"negotiator@npm:0.6.3": - version: 0.6.3 - resolution: "negotiator@npm:0.6.3" - checksum: 10c0/3ec9fd413e7bf071c937ae60d572bc67155262068ed522cf4b3be5edbe6ddf67d095ec03a3a14ebf8fc8e95f8e1d61be4869db0dbb0de696f6b837358bd43fc2 - languageName: node - linkType: hard - "negotiator@npm:^1.0.0": version: 1.0.0 resolution: "negotiator@npm:1.0.0" @@ -21353,15 +21275,6 @@ __metadata: languageName: node linkType: hard -"qrcode.react@npm:^4.2.0": - version: 4.2.0 - resolution: "qrcode.react@npm:4.2.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - checksum: 10c0/68c691d130e5fda2f57cee505ed7aea840e7d02033100687b764601f9595e1116e34c13876628a93e1a5c2b85e4efc27d30b2fda72e2050c02f3e1c4e998d248 - languageName: node - linkType: hard - "qs@npm:^6.14.0": version: 6.14.0 resolution: "qs@npm:6.14.0" @@ -23638,41 +23551,6 @@ __metadata: languageName: node linkType: hard -"socket.io-adapter@npm:~2.5.2": - version: 2.5.5 - resolution: "socket.io-adapter@npm:2.5.5" - dependencies: - debug: "npm:~4.3.4" - ws: "npm:~8.17.1" - checksum: 10c0/04a5a2a9c4399d1b6597c2afc4492ab1e73430cc124ab02b09e948eabf341180b3866e2b61b5084cb899beb68a4db7c328c29bda5efb9207671b5cb0bc6de44e - languageName: node - linkType: hard - -"socket.io-parser@npm:~4.2.4": - version: 4.2.4 - resolution: "socket.io-parser@npm:4.2.4" - dependencies: - "@socket.io/component-emitter": "npm:~3.1.0" - debug: "npm:~4.3.1" - checksum: 10c0/9383b30358fde4a801ea4ec5e6860915c0389a091321f1c1f41506618b5cf7cd685d0a31c587467a0c4ee99ef98c2b99fb87911f9dfb329716c43b587f29ca48 - languageName: node - linkType: hard - -"socket.io@npm:^4.8.1": - version: 4.8.1 - resolution: "socket.io@npm:4.8.1" - dependencies: - accepts: "npm:~1.3.4" - base64id: "npm:~2.0.0" - cors: "npm:~2.8.5" - debug: "npm:~4.3.2" - engine.io: "npm:~6.6.0" - socket.io-adapter: "npm:~2.5.2" - socket.io-parser: "npm:~4.2.4" - checksum: 10c0/acf931a2bb235be96433b71da3d8addc63eeeaa8acabd33dc8d64e12287390a45f1e9f389a73cf7dc336961cd491679741b7a016048325c596835abbcc017ca9 - languageName: node - linkType: hard - "socks-proxy-agent@npm:^8.0.3, socks-proxy-agent@npm:^8.0.5": version: 8.0.5 resolution: "socks-proxy-agent@npm:8.0.5" @@ -25169,13 +25047,6 @@ __metadata: languageName: node linkType: hard -"undici-types@npm:~7.10.0": - version: 7.10.0 - resolution: "undici-types@npm:7.10.0" - checksum: 10c0/8b00ce50e235fe3cc601307f148b5e8fb427092ee3b23e8118ec0a5d7f68eca8cee468c8fc9f15cbb2cf2a3797945ebceb1cbd9732306a1d00e0a9b6afa0f635 - languageName: node - linkType: hard - "undici@npm:6.21.2": version: 6.21.2 resolution: "undici@npm:6.21.2" @@ -26203,21 +26074,6 @@ __metadata: languageName: node linkType: hard -"ws@npm:~8.17.1": - version: 8.17.1 - resolution: "ws@npm:8.17.1" - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: ">=5.0.2" - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - checksum: 10c0/f4a49064afae4500be772abdc2211c8518f39e1c959640457dcee15d4488628620625c783902a52af2dd02f68558da2868fd06e6fd0e67ebcd09e6881b1b5bfe - languageName: node - linkType: hard - "xlsx@https://cdn.sheetjs.com/xlsx-0.20.2/xlsx-0.20.2.tgz": version: 0.20.2 resolution: "xlsx@https://cdn.sheetjs.com/xlsx-0.20.2/xlsx-0.20.2.tgz" From 483dcb1dfc29ff77047ea02a339e6d623c84fcd4 Mon Sep 17 00:00:00 2001 From: eeee0717 Date: Thu, 18 Dec 2025 11:32:13 +0800 Subject: [PATCH 6/6] fix: pr review --- docs/zh/references/lan-transfer-protocol.md | 48 +- packages/shared/config/types.ts | 8 +- .../BackupManager.deleteTempBackup.test.ts | 277 ++++++++++ .../__tests__/LocalTransferService.test.ts | 481 ++++++++++++++++++ .../lanTransfer/LanTransferClientService.ts | 18 +- 5 files changed, 775 insertions(+), 57 deletions(-) create mode 100644 src/main/services/__tests__/BackupManager.deleteTempBackup.test.ts create mode 100644 src/main/services/__tests__/LocalTransferService.test.ts diff --git a/docs/zh/references/lan-transfer-protocol.md b/docs/zh/references/lan-transfer-protocol.md index 5addd6201..a4c01a23c 100644 --- a/docs/zh/references/lan-transfer-protocol.md +++ b/docs/zh/references/lan-transfer-protocol.md @@ -222,52 +222,6 @@ function sendControlMessage(socket: Socket, message: object): void { {"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. 文件传输协议 @@ -802,7 +756,7 @@ class FileReceiver { ## 附录 A:TypeScript 类型定义 -完整的类型定义位于 `src/types/lanTransfer.ts`: +完整的类型定义位于 `packages/shared/config/types.ts`: ```typescript // 握手消息 diff --git a/packages/shared/config/types.ts b/packages/shared/config/types.ts index 0cc811f6d..56f746b0d 100644 --- a/packages/shared/config/types.ts +++ b/packages/shared/config/types.ts @@ -161,7 +161,7 @@ export const LAN_TRANSFER_MAX_FILE_SIZE = 500 * 1024 * 1024 // 500MB export const LAN_TRANSFER_COMPLETE_TIMEOUT_MS = 60_000 // 60s - wait for file_complete after file_end export const LAN_TRANSFER_GLOBAL_TIMEOUT_MS = 10 * 60 * 1000 // 10 minutes - global transfer timeout -// Binary protocol constants (v3) +// Binary protocol constants (v1) export const LAN_TRANSFER_PROTOCOL_VERSION = '1' export const LAN_BINARY_FRAME_MAGIC = 0x4353 // "CS" as uint16 export const LAN_BINARY_TYPE_FILE_CHUNK = 0x01 @@ -182,7 +182,7 @@ export type LanFileStartMessage = { /** * File chunk data (JSON format) - * @deprecated Use binary frame format in protocol v2. This type is kept for reference only. + * @deprecated Use binary frame format in protocol v1. This type is kept for reference only. */ export type LanFileChunkMessage = { type: 'file_chunk' @@ -217,7 +217,7 @@ export type LanFileStartAckMessage = { /** * Acknowledgment of file chunk received - * @deprecated Protocol v3 uses streaming mode without per-chunk acknowledgment. + * @deprecated Protocol v1 uses streaming mode without per-chunk acknowledgment. * This type is kept for backward compatibility reference only. */ export type LanFileChunkAckMessage = { @@ -235,7 +235,7 @@ export type LanFileCompleteMessage = { success: boolean filePath?: string // Path where file was saved on mobile error?: string - // v3 enhanced error diagnostics + // Enhanced error diagnostics errorCode?: 'CHECKSUM_MISMATCH' | 'INCOMPLETE_TRANSFER' | 'DISK_ERROR' | 'CANCELLED' receivedChunks?: number receivedBytes?: number diff --git a/src/main/services/__tests__/BackupManager.deleteTempBackup.test.ts b/src/main/services/__tests__/BackupManager.deleteTempBackup.test.ts new file mode 100644 index 000000000..631ae0551 --- /dev/null +++ b/src/main/services/__tests__/BackupManager.deleteTempBackup.test.ts @@ -0,0 +1,277 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// Use vi.hoisted to define mocks that are available during hoisting +const { mockLogger } = vi.hoisted(() => ({ + mockLogger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn() + } +})) + +vi.mock('@logger', () => ({ + loggerService: { + withContext: () => mockLogger + } +})) + +vi.mock('electron', () => ({ + app: { + getPath: vi.fn((key: string) => { + if (key === 'temp') return '/tmp' + if (key === 'userData') return '/mock/userData' + return '/mock/unknown' + }) + } +})) + +vi.mock('fs-extra', () => ({ + default: { + pathExists: vi.fn(), + remove: vi.fn(), + ensureDir: vi.fn(), + copy: vi.fn(), + readdir: vi.fn(), + stat: vi.fn(), + readFile: vi.fn(), + writeFile: vi.fn(), + createWriteStream: vi.fn(), + createReadStream: vi.fn() + }, + pathExists: vi.fn(), + remove: vi.fn(), + ensureDir: vi.fn(), + copy: vi.fn(), + readdir: vi.fn(), + stat: vi.fn(), + readFile: vi.fn(), + writeFile: vi.fn(), + createWriteStream: vi.fn(), + createReadStream: vi.fn() +})) + +vi.mock('../WindowService', () => ({ + windowService: { + getMainWindow: vi.fn() + } +})) + +vi.mock('../WebDav', () => ({ + default: vi.fn() +})) + +vi.mock('../S3Storage', () => ({ + default: vi.fn() +})) + +vi.mock('../../utils', () => ({ + getDataPath: vi.fn(() => '/mock/data') +})) + +vi.mock('archiver', () => ({ + default: vi.fn() +})) + +vi.mock('node-stream-zip', () => ({ + default: vi.fn() +})) + +// Import after mocks +import * as fs from 'fs-extra' + +import BackupManager from '../BackupManager' + +describe('BackupManager.deleteTempBackup - Security Tests', () => { + let backupManager: BackupManager + + beforeEach(() => { + vi.clearAllMocks() + backupManager = new BackupManager() + }) + + describe('Normal Operations', () => { + it('should delete valid file in allowed directory', async () => { + vi.mocked(fs.pathExists).mockResolvedValue(true as never) + vi.mocked(fs.remove).mockResolvedValue(undefined as never) + + const validPath = '/tmp/cherry-studio/lan-transfer/backup.zip' + const result = await backupManager.deleteTempBackup({} as Electron.IpcMainInvokeEvent, validPath) + + expect(result).toBe(true) + expect(fs.remove).toHaveBeenCalledWith(validPath) + expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining('Deleted temp backup')) + }) + + it('should delete file in nested subdirectory', async () => { + vi.mocked(fs.pathExists).mockResolvedValue(true as never) + vi.mocked(fs.remove).mockResolvedValue(undefined as never) + + const nestedPath = '/tmp/cherry-studio/lan-transfer/sub/dir/file.zip' + const result = await backupManager.deleteTempBackup({} as Electron.IpcMainInvokeEvent, nestedPath) + + expect(result).toBe(true) + expect(fs.remove).toHaveBeenCalledWith(nestedPath) + }) + + it('should return false when file does not exist', async () => { + vi.mocked(fs.pathExists).mockResolvedValue(false as never) + + const missingPath = '/tmp/cherry-studio/lan-transfer/missing.zip' + const result = await backupManager.deleteTempBackup({} as Electron.IpcMainInvokeEvent, missingPath) + + expect(result).toBe(false) + expect(fs.remove).not.toHaveBeenCalled() + }) + }) + + describe('Path Traversal Attacks', () => { + it('should block basic directory traversal attack (../../../../etc/passwd)', async () => { + const attackPath = '/tmp/cherry-studio/lan-transfer/../../../../etc/passwd' + const result = await backupManager.deleteTempBackup({} as Electron.IpcMainInvokeEvent, attackPath) + + expect(result).toBe(false) + expect(fs.pathExists).not.toHaveBeenCalled() + expect(fs.remove).not.toHaveBeenCalled() + expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('outside temp directory')) + }) + + it('should block absolute path escape (/etc/passwd)', async () => { + const attackPath = '/etc/passwd' + const result = await backupManager.deleteTempBackup({} as Electron.IpcMainInvokeEvent, attackPath) + + expect(result).toBe(false) + expect(fs.remove).not.toHaveBeenCalled() + expect(mockLogger.warn).toHaveBeenCalled() + }) + + it('should block traversal with multiple slashes', async () => { + const attackPath = '/tmp/cherry-studio/lan-transfer/../../../etc/passwd' + const result = await backupManager.deleteTempBackup({} as Electron.IpcMainInvokeEvent, attackPath) + + expect(result).toBe(false) + expect(fs.remove).not.toHaveBeenCalled() + }) + + it('should block relative path traversal from current directory', async () => { + const attackPath = '../../../etc/passwd' + const result = await backupManager.deleteTempBackup({} as Electron.IpcMainInvokeEvent, attackPath) + + expect(result).toBe(false) + expect(fs.remove).not.toHaveBeenCalled() + }) + + it('should block traversal to parent directory', async () => { + const attackPath = '/tmp/cherry-studio/lan-transfer/../backup/secret.zip' + const result = await backupManager.deleteTempBackup({} as Electron.IpcMainInvokeEvent, attackPath) + + expect(result).toBe(false) + expect(fs.remove).not.toHaveBeenCalled() + }) + }) + + describe('Prefix Attacks', () => { + it('should block similar prefix attack (lan-transfer-evil)', async () => { + const attackPath = '/tmp/cherry-studio/lan-transfer-evil/file.zip' + const result = await backupManager.deleteTempBackup({} as Electron.IpcMainInvokeEvent, attackPath) + + expect(result).toBe(false) + expect(fs.remove).not.toHaveBeenCalled() + expect(mockLogger.warn).toHaveBeenCalled() + }) + + it('should block path without separator (lan-transferx)', async () => { + const attackPath = '/tmp/cherry-studio/lan-transferx' + const result = await backupManager.deleteTempBackup({} as Electron.IpcMainInvokeEvent, attackPath) + + expect(result).toBe(false) + expect(fs.remove).not.toHaveBeenCalled() + }) + + it('should block different temp directory prefix', async () => { + const attackPath = '/tmp-evil/cherry-studio/lan-transfer/file.zip' + const result = await backupManager.deleteTempBackup({} as Electron.IpcMainInvokeEvent, attackPath) + + expect(result).toBe(false) + expect(fs.remove).not.toHaveBeenCalled() + }) + }) + + describe('Error Handling', () => { + it('should return false and log error on permission denied', async () => { + vi.mocked(fs.pathExists).mockResolvedValue(true as never) + vi.mocked(fs.remove).mockRejectedValue(new Error('EACCES: permission denied') as never) + + const validPath = '/tmp/cherry-studio/lan-transfer/file.zip' + const result = await backupManager.deleteTempBackup({} as Electron.IpcMainInvokeEvent, validPath) + + expect(result).toBe(false) + expect(mockLogger.error).toHaveBeenCalledWith( + expect.stringContaining('Failed to delete'), + expect.any(Error) + ) + }) + + it('should return false on fs.pathExists error', async () => { + vi.mocked(fs.pathExists).mockRejectedValue(new Error('ENOENT') as never) + + const validPath = '/tmp/cherry-studio/lan-transfer/file.zip' + const result = await backupManager.deleteTempBackup({} as Electron.IpcMainInvokeEvent, validPath) + + expect(result).toBe(false) + expect(mockLogger.error).toHaveBeenCalled() + }) + + it('should handle empty path string', async () => { + const result = await backupManager.deleteTempBackup({} as Electron.IpcMainInvokeEvent, '') + + expect(result).toBe(false) + expect(fs.remove).not.toHaveBeenCalled() + }) + }) + + describe('Edge Cases', () => { + it('should allow deletion of the temp directory itself', async () => { + vi.mocked(fs.pathExists).mockResolvedValue(true as never) + vi.mocked(fs.remove).mockResolvedValue(undefined as never) + + const tempDir = '/tmp/cherry-studio/lan-transfer' + const result = await backupManager.deleteTempBackup({} as Electron.IpcMainInvokeEvent, tempDir) + + expect(result).toBe(true) + expect(fs.remove).toHaveBeenCalledWith(tempDir) + }) + + it('should handle path with trailing slash', async () => { + vi.mocked(fs.pathExists).mockResolvedValue(true as never) + vi.mocked(fs.remove).mockResolvedValue(undefined as never) + + const pathWithSlash = '/tmp/cherry-studio/lan-transfer/sub/' + const result = await backupManager.deleteTempBackup({} as Electron.IpcMainInvokeEvent, pathWithSlash) + + // path.normalize removes trailing slash + expect(result).toBe(true) + }) + + it('should handle file with special characters in name', async () => { + vi.mocked(fs.pathExists).mockResolvedValue(true as never) + vi.mocked(fs.remove).mockResolvedValue(undefined as never) + + const specialPath = '/tmp/cherry-studio/lan-transfer/file with spaces & (special).zip' + const result = await backupManager.deleteTempBackup({} as Electron.IpcMainInvokeEvent, specialPath) + + expect(result).toBe(true) + expect(fs.remove).toHaveBeenCalled() + }) + + it('should handle path with double slashes', async () => { + vi.mocked(fs.pathExists).mockResolvedValue(true as never) + vi.mocked(fs.remove).mockResolvedValue(undefined as never) + + const doubleSlashPath = '/tmp/cherry-studio//lan-transfer//file.zip' + const result = await backupManager.deleteTempBackup({} as Electron.IpcMainInvokeEvent, doubleSlashPath) + + // path.normalize handles double slashes + expect(result).toBe(true) + }) + }) +}) diff --git a/src/main/services/__tests__/LocalTransferService.test.ts b/src/main/services/__tests__/LocalTransferService.test.ts new file mode 100644 index 000000000..84f876a60 --- /dev/null +++ b/src/main/services/__tests__/LocalTransferService.test.ts @@ -0,0 +1,481 @@ +import { EventEmitter } from 'events' +import { afterEach, beforeEach, describe, expect, it, type Mock,vi } from 'vitest' + +// Create mock objects before vi.mock calls +const mockLogger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn() +} + +let mockMainWindow: { + isDestroyed: Mock + webContents: { send: Mock } +} | null = null + +let mockBrowser: EventEmitter & { + start: Mock + stop: Mock + removeAllListeners: Mock +} + +let mockBonjour: { + find: Mock + destroy: Mock +} + +// Mock dependencies before importing the service +vi.mock('@logger', () => ({ + loggerService: { + withContext: () => mockLogger + } +})) + +vi.mock('../WindowService', () => ({ + windowService: { + getMainWindow: vi.fn(() => mockMainWindow) + } +})) + +vi.mock('bonjour-service', () => ({ + default: vi.fn(() => mockBonjour) +})) + +describe('LocalTransferService', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.resetModules() + + // Reset mock objects + mockMainWindow = { + isDestroyed: vi.fn(() => false), + webContents: { send: vi.fn() } + } + + mockBrowser = Object.assign(new EventEmitter(), { + start: vi.fn(), + stop: vi.fn(), + removeAllListeners: vi.fn() + }) + + mockBonjour = { + find: vi.fn(() => mockBrowser), + destroy: vi.fn() + } + }) + + afterEach(() => { + vi.resetAllMocks() + }) + + describe('startDiscovery', () => { + it('should set isScanning to true and start browser', async () => { + const { localTransferService } = await import('../LocalTransferService') + + const state = localTransferService.startDiscovery() + + expect(state.isScanning).toBe(true) + expect(state.lastScanStartedAt).toBeDefined() + expect(mockBonjour.find).toHaveBeenCalledWith({ type: 'cherrystudio', protocol: 'tcp' }) + expect(mockBrowser.start).toHaveBeenCalled() + }) + + it('should clear services when resetList is true', async () => { + const { localTransferService } = await import('../LocalTransferService') + + // First, start discovery and add a service + localTransferService.startDiscovery() + mockBrowser.emit('up', { + name: 'Test Service', + host: 'localhost', + port: 12345, + addresses: ['192.168.1.100'], + fqdn: 'test.local' + }) + + expect(localTransferService.getState().services).toHaveLength(1) + + // Now restart with resetList + const state = localTransferService.startDiscovery({ resetList: true }) + + expect(state.services).toHaveLength(0) + }) + + it('should broadcast state after starting discovery', async () => { + const { localTransferService } = await import('../LocalTransferService') + + localTransferService.startDiscovery() + + expect(mockMainWindow?.webContents.send).toHaveBeenCalled() + }) + + it('should handle browser.start() error', async () => { + mockBrowser.start.mockImplementation(() => { + throw new Error('Failed to start mDNS') + }) + + const { localTransferService } = await import('../LocalTransferService') + + const state = localTransferService.startDiscovery() + + expect(state.lastError).toBe('Failed to start mDNS') + expect(mockLogger.error).toHaveBeenCalled() + }) + }) + + describe('stopDiscovery', () => { + it('should set isScanning to false and stop browser', async () => { + const { localTransferService } = await import('../LocalTransferService') + + localTransferService.startDiscovery() + const state = localTransferService.stopDiscovery() + + expect(state.isScanning).toBe(false) + expect(mockBrowser.stop).toHaveBeenCalled() + }) + + it('should handle browser.stop() error gracefully', async () => { + mockBrowser.stop.mockImplementation(() => { + throw new Error('Stop failed') + }) + + const { localTransferService } = await import('../LocalTransferService') + + localTransferService.startDiscovery() + + // Should not throw + expect(() => localTransferService.stopDiscovery()).not.toThrow() + expect(mockLogger.warn).toHaveBeenCalled() + }) + + it('should broadcast state after stopping', async () => { + const { localTransferService } = await import('../LocalTransferService') + + localTransferService.startDiscovery() + vi.clearAllMocks() + + localTransferService.stopDiscovery() + + expect(mockMainWindow?.webContents.send).toHaveBeenCalled() + }) + }) + + describe('browser events', () => { + it('should add service on "up" event', async () => { + const { localTransferService } = await import('../LocalTransferService') + + localTransferService.startDiscovery() + + mockBrowser.emit('up', { + name: 'Test Service', + host: 'localhost', + port: 12345, + addresses: ['192.168.1.100'], + fqdn: 'test.local', + type: 'cherrystudio', + protocol: 'tcp' + }) + + const state = localTransferService.getState() + expect(state.services).toHaveLength(1) + expect(state.services[0].name).toBe('Test Service') + expect(state.services[0].port).toBe(12345) + expect(state.services[0].addresses).toContain('192.168.1.100') + }) + + it('should remove service on "down" event', async () => { + const { localTransferService } = await import('../LocalTransferService') + + localTransferService.startDiscovery() + + // Add service + mockBrowser.emit('up', { + name: 'Test Service', + host: 'localhost', + port: 12345, + addresses: ['192.168.1.100'], + fqdn: 'test.local' + }) + + expect(localTransferService.getState().services).toHaveLength(1) + + // Remove service + mockBrowser.emit('down', { + name: 'Test Service', + host: 'localhost', + port: 12345, + fqdn: 'test.local' + }) + + expect(localTransferService.getState().services).toHaveLength(0) + expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining('removed')) + }) + + it('should set lastError on "error" event', async () => { + const { localTransferService } = await import('../LocalTransferService') + + localTransferService.startDiscovery() + + mockBrowser.emit('error', new Error('Discovery failed')) + + const state = localTransferService.getState() + expect(state.lastError).toBe('Discovery failed') + expect(mockLogger.error).toHaveBeenCalled() + }) + + it('should handle non-Error objects in error event', async () => { + const { localTransferService } = await import('../LocalTransferService') + + localTransferService.startDiscovery() + + mockBrowser.emit('error', 'String error message') + + const state = localTransferService.getState() + expect(state.lastError).toBe('String error message') + }) + }) + + describe('getState', () => { + it('should return sorted services by name', async () => { + const { localTransferService } = await import('../LocalTransferService') + + localTransferService.startDiscovery() + + mockBrowser.emit('up', { + name: 'Zebra Service', + host: 'host1', + port: 1001, + addresses: ['192.168.1.1'] + }) + + mockBrowser.emit('up', { + name: 'Alpha Service', + host: 'host2', + port: 1002, + addresses: ['192.168.1.2'] + }) + + const state = localTransferService.getState() + expect(state.services[0].name).toBe('Alpha Service') + expect(state.services[1].name).toBe('Zebra Service') + }) + + it('should include all state properties', async () => { + const { localTransferService } = await import('../LocalTransferService') + + localTransferService.startDiscovery() + + const state = localTransferService.getState() + + expect(state).toHaveProperty('services') + expect(state).toHaveProperty('isScanning') + expect(state).toHaveProperty('lastScanStartedAt') + expect(state).toHaveProperty('lastUpdatedAt') + }) + }) + + describe('getPeerById', () => { + it('should return peer when exists', async () => { + const { localTransferService } = await import('../LocalTransferService') + + localTransferService.startDiscovery() + + mockBrowser.emit('up', { + name: 'Test Service', + host: 'localhost', + port: 12345, + addresses: ['192.168.1.100'], + fqdn: 'test.local' + }) + + const services = localTransferService.getState().services + const peer = localTransferService.getPeerById(services[0].id) + + expect(peer).toBeDefined() + expect(peer?.name).toBe('Test Service') + }) + + it('should return undefined when peer does not exist', async () => { + const { localTransferService } = await import('../LocalTransferService') + + const peer = localTransferService.getPeerById('non-existent-id') + + expect(peer).toBeUndefined() + }) + }) + + describe('normalizeService', () => { + it('should deduplicate addresses', async () => { + const { localTransferService } = await import('../LocalTransferService') + + localTransferService.startDiscovery() + + mockBrowser.emit('up', { + name: 'Test Service', + host: 'localhost', + port: 12345, + addresses: ['192.168.1.100', '192.168.1.100', '10.0.0.1'], + referer: { address: '192.168.1.100' } + }) + + const services = localTransferService.getState().services + expect(services[0].addresses).toHaveLength(2) + expect(services[0].addresses).toContain('192.168.1.100') + expect(services[0].addresses).toContain('10.0.0.1') + }) + + it('should filter empty addresses', async () => { + const { localTransferService } = await import('../LocalTransferService') + + localTransferService.startDiscovery() + + mockBrowser.emit('up', { + name: 'Test Service', + host: 'localhost', + port: 12345, + addresses: ['192.168.1.100', '', null as any] + }) + + const services = localTransferService.getState().services + expect(services[0].addresses).toEqual(['192.168.1.100']) + }) + + it('should convert txt null/undefined values to empty strings', async () => { + const { localTransferService } = await import('../LocalTransferService') + + localTransferService.startDiscovery() + + mockBrowser.emit('up', { + name: 'Test Service', + host: 'localhost', + port: 12345, + addresses: ['192.168.1.100'], + txt: { + version: '1.0', + nullValue: null, + undefinedValue: undefined, + numberValue: 42 + } + }) + + const services = localTransferService.getState().services + expect(services[0].txt).toEqual({ + version: '1.0', + nullValue: '', + undefinedValue: '', + numberValue: '42' + }) + }) + + it('should not include txt when empty', async () => { + const { localTransferService } = await import('../LocalTransferService') + + localTransferService.startDiscovery() + + mockBrowser.emit('up', { + name: 'Test Service', + host: 'localhost', + port: 12345, + addresses: ['192.168.1.100'], + txt: {} + }) + + const services = localTransferService.getState().services + expect(services[0].txt).toBeUndefined() + }) + }) + + describe('dispose', () => { + it('should clean up all resources', async () => { + const { localTransferService } = await import('../LocalTransferService') + + localTransferService.startDiscovery() + + mockBrowser.emit('up', { + name: 'Test Service', + host: 'localhost', + port: 12345, + addresses: ['192.168.1.100'] + }) + + localTransferService.dispose() + + expect(localTransferService.getState().services).toHaveLength(0) + expect(localTransferService.getState().isScanning).toBe(false) + expect(mockBrowser.removeAllListeners).toHaveBeenCalled() + expect(mockBonjour.destroy).toHaveBeenCalled() + }) + + it('should handle bonjour.destroy() error gracefully', async () => { + mockBonjour.destroy.mockImplementation(() => { + throw new Error('Destroy failed') + }) + + const { localTransferService } = await import('../LocalTransferService') + + localTransferService.startDiscovery() + + // Should not throw + expect(() => localTransferService.dispose()).not.toThrow() + expect(mockLogger.warn).toHaveBeenCalled() + }) + + it('should be safe to call multiple times', async () => { + const { localTransferService } = await import('../LocalTransferService') + + localTransferService.startDiscovery() + + expect(() => { + localTransferService.dispose() + localTransferService.dispose() + }).not.toThrow() + }) + }) + + describe('broadcastState', () => { + it('should not throw when main window is null', async () => { + mockMainWindow = null + + const { localTransferService } = await import('../LocalTransferService') + + // Should not throw + expect(() => localTransferService.startDiscovery()).not.toThrow() + }) + + it('should not throw when main window is destroyed', async () => { + mockMainWindow = { + isDestroyed: vi.fn(() => true), + webContents: { send: vi.fn() } + } + + const { localTransferService } = await import('../LocalTransferService') + + // Should not throw + expect(() => localTransferService.startDiscovery()).not.toThrow() + expect(mockMainWindow.webContents.send).not.toHaveBeenCalled() + }) + }) + + describe('restartBrowser', () => { + it('should destroy old bonjour instance to prevent socket leaks', async () => { + const { localTransferService } = await import('../LocalTransferService') + + // First start + localTransferService.startDiscovery() + expect(mockBonjour.destroy).not.toHaveBeenCalled() + + // Restart - should destroy old instance + localTransferService.startDiscovery() + expect(mockBonjour.destroy).toHaveBeenCalled() + }) + + it('should remove all listeners from old browser', async () => { + const { localTransferService } = await import('../LocalTransferService') + + localTransferService.startDiscovery() + localTransferService.startDiscovery() + + expect(mockBrowser.removeAllListeners).toHaveBeenCalled() + }) + }) +}) diff --git a/src/main/services/lanTransfer/LanTransferClientService.ts b/src/main/services/lanTransfer/LanTransferClientService.ts index 68b6a82b9..a6da2f1a2 100644 --- a/src/main/services/lanTransfer/LanTransferClientService.ts +++ b/src/main/services/lanTransfer/LanTransferClientService.ts @@ -331,11 +331,10 @@ class LanTransferClientService { logger.info('Connection lost, attempting to reconnect...') this.reconnectPromise = this.connectAndHandshake(this.lastConnectOptions) .then(() => { - this.reconnectPromise = null + // Handshake succeeded, connection restored }) - .catch((error) => { + .finally(() => { this.reconnectPromise = null - throw error }) await this.reconnectPromise @@ -406,7 +405,14 @@ class LanTransferClientService { private attachSocketListeners(socket: Socket): void { this.dataHandler = createDataHandler((line) => this.handleControlLine(line)) - socket.on('data', (chunk: Buffer) => this.dataHandler?.handleData(chunk)) + socket.on('data', (chunk: Buffer) => { + try { + this.dataHandler?.handleData(chunk) + } catch (error) { + logger.error('Data handler error', error as Error) + void this.disconnect() + } + }) } private handleControlLine(line: string): void { @@ -419,14 +425,14 @@ class LanTransferClientService { logger.warn('Received invalid JSON control message', { line, consecutiveErrors: this.consecutiveJsonErrors }) if (this.consecutiveJsonErrors >= LanTransferClientService.MAX_CONSECUTIVE_JSON_ERRORS) { - const message = `Protocol error: ${this.consecutiveJsonErrors} consecutive invalid messages` + const message = `Protocol error: ${this.consecutiveJsonErrors} consecutive invalid messages, disconnecting` logger.error(message) this.broadcastClientEvent({ type: 'error', message, timestamp: Date.now() }) - this.consecutiveJsonErrors = 0 + void this.disconnect() } return }