refactor: change qrcode landrop to lantransfer

This commit is contained in:
eeee0717 2025-12-17 20:22:15 +08:00
parent f8c33db450
commit 37db8f1c9c
40 changed files with 4422 additions and 558 deletions

View File

@ -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<string, string>; // 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_cancelUTF-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-endiantransferId 字符串长度 |
| 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<string> {
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
}
}
```
---
## 附录 ATypeScript 类型定义
完整的类型定义位于 `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` 错误诊断 |

View File

@ -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",

View File

@ -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'
}

View File

@ -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<string, string>
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<string, string>
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
}

View File

@ -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 () => {

View File

@ -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()
})

View File

@ -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<string> {
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<boolean> {
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

View File

@ -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<string, LocalTransferPeer>()
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()

View File

@ -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<typeof createDataHandler>
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<LanHandshakeAckMessage> {
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<LanHandshakeAckMessage>((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<void> {
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<void>((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<LanFileCompleteMessage> {
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<void> {
// 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<LanFileCompleteMessage> {
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<string, unknown>
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<string, unknown>): 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 }

View File

@ -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<typeof vi.fn>).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)
})
})
})

View File

@ -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')
})
})
})

View File

@ -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')
})
})
})

View File

@ -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<unknown>((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<unknown>((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<unknown>((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<unknown>((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<unknown>((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<unknown>((resolve, reject) => {
manager.waitForResponse('test1', 5000, resolve, reject)
}),
new Promise<unknown>((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')
})
})
})

View File

@ -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
}

View File

@ -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<void> {
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<void>((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)
}

View File

@ -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<string> {
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<LanFileStartAckMessage> {
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<LanFileCompleteMessage> {
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<void> {
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]
}

View File

@ -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'

View File

@ -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'

View File

@ -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<string, PendingResponse>()
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)
}
}

View File

@ -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<string, unknown>) => 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
}

View File

@ -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<string> =>
ipcRenderer.invoke(IpcChannel.Backup_CreateLanTransferBackup, data),
deleteTempBackup: (filePath: string): Promise<boolean> =>
ipcRenderer.invoke(IpcChannel.Backup_DeleteTempBackup, filePath)
},
file: {
select: (options?: OpenDialogOptions): Promise<FileMetadata[] | null> =>
@ -570,6 +582,33 @@ const api = {
writeContent: (options: WritePluginContentOptions): Promise<PluginResult<void>> =>
ipcRenderer.invoke(IpcChannel.ClaudeCodePlugin_WriteContent, options)
},
localTransfer: {
getState: (): Promise<LocalTransferState> => ipcRenderer.invoke(IpcChannel.LocalTransfer_ListServices),
startScan: (): Promise<LocalTransferState> => ipcRenderer.invoke(IpcChannel.LocalTransfer_StartScan),
stopScan: (): Promise<LocalTransferState> => ipcRenderer.invoke(IpcChannel.LocalTransfer_StopScan),
connect: (payload: LocalTransferConnectPayload): Promise<LanHandshakeAckMessage> =>
ipcRenderer.invoke(IpcChannel.LocalTransfer_Connect, payload),
disconnect: (): Promise<void> => 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<LanFileCompleteMessage> =>
ipcRenderer.invoke(IpcChannel.LocalTransfer_SendFile, { filePath }),
cancelTransfer: (): Promise<void> => ipcRenderer.invoke(IpcChannel.LocalTransfer_CancelTransfer)
},
webSocket: {
start: () => ipcRenderer.invoke(IpcChannel.WebSocket_Start),
stop: () => ipcRenderer.invoke(IpcChannel.WebSocket_Stop),

View File

@ -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 (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '12px' }}>
<Spin />
<span style={{ fontSize: '14px', color: 'var(--color-text-2)' }}>
{t('settings.data.export_to_phone.lan.generating_qr')}
</span>
</div>
)
}
const ScanQRCode: React.FC<{ qrCodeValue: string }> = ({ qrCodeValue }) => {
const { t } = useTranslation()
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '12px' }}>
<QRCodeSVG
marginSize={2}
value={qrCodeValue}
level="H"
size={200}
imageSettings={{
src: AppLogo,
width: 40,
height: 40,
excavate: true
}}
/>
<span style={{ fontSize: '12px', color: 'var(--color-text-2)' }}>
{t('settings.data.export_to_phone.lan.scan_qr')}
</span>
</div>
)
}
const ConnectingAnimation: React.FC = () => {
const { t } = useTranslation()
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '12px' }}>
<div
style={{
width: '160px',
height: '160px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
border: '2px dashed var(--color-status-warning)',
borderRadius: '12px',
backgroundColor: 'var(--color-status-warning)'
}}>
<Spin size="large" />
<span style={{ fontSize: '14px', color: 'var(--color-text)', marginTop: '12px' }}>
{t('settings.data.export_to_phone.lan.status.connecting')}
</span>
</div>
</div>
)
}
const ConnectedDisplay: React.FC = () => {
const { t } = useTranslation()
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '12px' }}>
<div
style={{
width: '160px',
height: '160px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
border: '2px dashed var(--color-status-success)',
borderRadius: '12px',
backgroundColor: 'var(--color-status-success)'
}}>
<span style={{ fontSize: '48px' }}>📱</span>
<span style={{ fontSize: '14px', color: 'var(--color-text)', marginTop: '8px' }}>
{t('settings.data.export_to_phone.lan.connected')}
</span>
</div>
</div>
)
}
const ErrorQRCode: React.FC<{ error: string | null }> = ({ error }) => {
const { t } = useTranslation()
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '12px',
padding: '20px',
border: `1px solid var(--color-error)`,
borderRadius: '8px',
backgroundColor: 'var(--color-error)'
}}>
<span style={{ fontSize: '48px' }}></span>
<span style={{ fontSize: '14px', color: 'var(--color-text)' }}>
{t('settings.data.export_to_phone.lan.connection_failed')}
</span>
{error && <span style={{ fontSize: '12px', color: 'var(--color-text-2)' }}>{error}</span>}
</div>
)
}
const PopupContainer: React.FC<Props> = ({ resolve }) => {
const [isOpen, setIsOpen] = useState(true)
const [connectionPhase, setConnectionPhase] = useState<ConnectionPhase>('initializing')
const [transferPhase, setTransferPhase] = useState<TransferPhase>('idle')
const [qrCodeValue, setQrCodeValue] = useState('')
const [selectedFolderPath, setSelectedFolderPath] = useState<string | null>(null)
const [sendProgress, setSendProgress] = useState(0)
const [error, setError] = useState<string | null>(null)
const [autoCloseCountdown, setAutoCloseCountdown] = useState<number | null>(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(
() => (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px',
padding: '5px 12px',
width: '100%',
backgroundColor: connectionStatusStyles.bg,
border: `1px solid ${connectionStatusStyles.border}`,
marginBottom: 10
}}>
<span style={{ fontSize: '14px', fontWeight: '500', color: 'var(--color-text)' }}>{connectionStatusText}</span>
</div>
),
[connectionStatusStyles, connectionStatusText]
)
// 二维码显示组件 - 使用显式条件渲染以避免类型不匹配
const QRCodeDisplay = useCallback(() => {
switch (connectionPhase) {
case 'waiting_qr_scan':
case 'disconnected':
return <ScanQRCode qrCodeValue={qrCodeValue} />
case 'initializing':
return <LoadingQRCode />
case 'connecting':
return <ConnectingAnimation />
case 'connected':
return <ConnectedDisplay />
case 'error':
return <ErrorQRCode error={error} />
default:
return null
}
}, [connectionPhase, qrCodeValue, error])
// 传输进度组件
const TransferProgress = useCallback(() => {
if (!isSending && transferPhase !== 'completed') return null
return (
<div style={{ paddingTop: '20px' }}>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '8px',
padding: '12px',
border: `1px solid var(--color-border)`,
borderRadius: '8px',
backgroundColor: 'var(--color-background-mute)'
}}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
fontSize: '14px',
fontWeight: '500'
}}>
<span style={{ color: 'var(--color-text)' }}>
{t('settings.data.export_to_phone.lan.transfer_progress')}
</span>
<span
style={{ color: transferPhase === 'completed' ? 'var(--color-status-success)' : 'var(--color-primary)' }}>
{transferPhase === 'completed' ? '✅ ' + t('common.completed') : `${Math.round(sendProgress)}%`}
</span>
</div>
<Progress
percent={Math.round(sendProgress)}
status={transferPhase === 'completed' ? 'success' : 'active'}
showInfo={false}
/>
</div>
</div>
)
}, [isSending, transferPhase, sendProgress, t])
const AutoCloseCountdown = useCallback(() => {
if (transferPhase !== 'completed' || autoCloseCountdown === null || autoCloseCountdown <= 0) return null
return (
<div
style={{
fontSize: '12px',
color: 'var(--color-text-2)',
textAlign: 'center',
paddingTop: '4px'
}}>
{t('settings.data.export_to_phone.lan.auto_close_tip', { seconds: autoCloseCountdown })}
</div>
)
}, [transferPhase, autoCloseCountdown, t])
// 错误显示组件
const ErrorDisplay = useCallback(() => {
if (!error || transferPhase !== 'error') return null
return (
<div
style={{
padding: '12px',
border: `1px solid var(--color-error)`,
borderRadius: '8px',
backgroundColor: 'var(--color-error)',
textAlign: 'center'
}}>
<span style={{ fontSize: '14px', color: 'var(--color-text)' }}> {error}</span>
</div>
)
}, [error, transferPhase])
return (
<Modal
open={isOpen}
onCancel={handleCancel}
afterClose={handleClose}
title={t('settings.data.export_to_phone.lan.title')}
centered
closable={!isSending}
maskClosable={false}
keyboard={true}
footer={null}
styles={{ body: { paddingBottom: 10 } }}>
<SettingRow>
<StatusIndicator />
</SettingRow>
<Alert message={t('settings.data.export_to_phone.lan.content')} type="info" style={{ borderRadius: 0 }} />
<SettingRow style={{ display: 'flex', justifyContent: 'center', minHeight: '180px', marginBlock: 25 }}>
<QRCodeDisplay />
</SettingRow>
<SettingRow style={{ display: 'flex', alignItems: 'center', marginBlock: 10 }}>
<div style={{ display: 'flex', gap: 10, justifyContent: 'center', width: '100%' }}>
<Button onClick={handleSelectZip} disabled={isSending}>
{t('settings.data.export_to_phone.lan.selectZip')}
</Button>
<Button type="primary" onClick={handleSendZip} disabled={!canSend} loading={isSending}>
{transferStatusText || t('settings.data.export_to_phone.lan.sendZip')}
</Button>
</div>
</SettingRow>
<SettingHelpText
style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
textAlign: 'center'
}}>
{selectedFolderPath || t('settings.data.export_to_phone.lan.noZipSelected')}
</SettingHelpText>
<TransferProgress />
<AutoCloseCountdown />
<ErrorDisplay />
</Modal>
)
}
const TopViewKey = 'ExportToPhoneLanPopup'
export default class ExportToPhoneLanPopup {
static topviewId = 0
static hide() {
TopView.hide(TopViewKey)
}
static show() {
return new Promise<any>((resolve) => {
TopView.show(
<PopupContainer
resolve={(v) => {
resolve(v)
TopView.hide(TopViewKey)
}}
/>,
TopViewKey
)
})
}
}

View File

@ -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<LanDeviceCardProps> = ({
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<HTMLDivElement> = (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
handleClick()
}
}
return (
<div
role="button"
tabIndex={0}
onClick={handleClick}
onKeyDown={handleKeyDown}
className={cn(
// Base styles
'flex cursor-pointer flex-col gap-2 rounded-xl border p-3 outline-none transition-all duration-[120ms]',
// Hover state
'hover:-translate-y-px hover:border-[var(--color-primary-hover)] hover:shadow-md',
// Focus state
'focus-visible:border-[var(--color-primary)] focus-visible:shadow-[0_0_0_2px_rgba(24,144,255,0.2)]',
// Connected state
isConnected
? 'border-[var(--color-primary)] bg-[rgba(24,144,255,0.04)]'
: 'border-[var(--color-border)] bg-[var(--color-background)]',
// Disabled state
isDisabled && 'pointer-events-none translate-y-0 opacity-70 shadow-none'
)}>
{/* Header */}
<div className="flex items-center justify-between gap-2">
<div className="flex flex-col gap-1">
<div className="break-words font-semibold text-[var(--color-text-1)] text-sm">{displayTitle}</div>
<span className="text-[var(--color-text-2)] text-xs">{statusText}</span>
</div>
</div>
{/* Meta Row - IP Address */}
<div className="flex flex-col gap-1">
<span className="text-[11px] text-[var(--color-text-3)] uppercase tracking-[0.03em]">
{t('settings.data.export_to_phone.lan.ip_addresses')}
</span>
<span className="break-words text-[var(--color-text)] text-xs">{addressesWithPort || t('common.unknown')}</span>
</div>
{/* Footer with Progress */}
<div className="flex flex-wrap items-center justify-between gap-2 text-[11px] text-[var(--color-text-3)]">
{shouldShowProgress && transferState && (
<ProgressIndicator transferState={transferState} handshakeInProgress={handshakeInProgress} />
)}
</div>
</div>
)
}

View File

@ -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<ProgressIndicatorProps> = ({ 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 (
<div className="flex min-w-[180px] flex-1 flex-col gap-1">
{/* Label Row */}
<div
className={cn(
'flex items-center justify-between gap-1.5 text-[11px]',
isFailed ? 'text-[var(--color-error)]' : 'text-[var(--color-text-2)]'
)}>
<span className="flex-1 overflow-hidden text-ellipsis whitespace-nowrap">{transferState.fileName}</span>
<span className="shrink-0 whitespace-nowrap">{progressLabel}</span>
</div>
{/* Progress Track */}
<div className="relative h-1.5 w-full overflow-hidden rounded-full bg-[var(--color-border)]">
<div
className={cn(
'h-full rounded-full transition-[width] duration-[120ms]',
isFailed
? 'bg-[var(--color-error)]'
: isCompleted
? 'bg-[var(--color-status-success)]'
: 'bg-[var(--color-primary)]'
)}
style={{ width: `${progressPercent}%` }}
/>
</div>
</div>
)
}

View File

@ -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<string, LanPeerTransferState> = {}
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<void>
handleModalCancel: () => void
getTransferState: (peerId: string) => LanPeerTransferState | undefined
isConnected: (peerId: string) => boolean
isHandshakeInProgress: (peerId: string) => boolean
// Dispatch (for advanced use)
dispatch: React.Dispatch<LanTransferAction>
}
// ==========================================
// 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<string | null>(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
}
}

View File

@ -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<PopupResolveData>((resolve) => {
TopView.show(
<PopupContainer
resolve={(v) => {
resolve(v)
TopView.hide(TopViewKey)
}}
/>,
TopViewKey
)
})
}
}

View File

@ -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<PopupContainerProps> = ({ 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 (
<Modal
open={state.open}
onCancel={handleModalCancel}
afterClose={onClose}
footer={null}
centered
title={contentTitle}
transitionName="animation-move-down">
<div className="flex flex-col gap-3">
{/* Error Display */}
{lastError && <div className="text-[var(--color-error)] text-xs">{lastError}</div>}
{/* Device List */}
<div className="mt-2 flex flex-col gap-3">
{lanDevices.length === 0 ? (
// Warning when no devices
<div className="flex w-full items-center gap-2.5 rounded-[10px] border border-[rgba(255,159,41,0.4)] border-dashed bg-[rgba(255,159,41,0.1)] px-3.5 py-3">
<TriangleAlert size={20} className="text-orange-400" />
<span className="flex-1 text-[#ff9f29] text-[13px] leading-[1.4]">
{t('settings.data.export_to_phone.lan.no_connection_warning')}
</span>
</div>
) : (
// 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 (
<LanDeviceCard
key={service.id}
service={service}
transferState={transferState}
isConnected={connected}
handshakeInProgress={handshakeInProgress}
isDisabled={isCardDisabled}
onSendFile={handleSendFile}
/>
)
})
)}
</div>
</div>
</Modal>
)
}

View File

@ -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<string, LanPeerTransferState>
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<LanPeerTransferState> } }
| { type: 'SET_TRANSFER_STATE'; payload: { peerId: string; state: LanPeerTransferState } }
| { type: 'CLEANUP_STALE_PEERS'; payload: Set<string> }
| { 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
}

View File

@ -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"
},

View File

@ -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": "传输进度"
},

View File

@ -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": "傳輸進度"
},

View File

@ -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"
},

View File

@ -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": "Πρόοδος μεταφοράς"
},

View File

@ -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"
},

View File

@ -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"
},

View File

@ -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": "転送進行"
},

View File

@ -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"
},

View File

@ -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": "Прогресс передачи"
},

View File

@ -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 = () => {
<SettingRow>
<SettingRowTitle>{t('settings.data.export_to_phone.title')}</SettingRowTitle>
<HStack gap="5px" justifyContent="space-between">
<Button onClick={ExportToPhoneLanPopup.show} icon={<WifiOutlined size={14} />}>
<Button onClick={LanTransferPopup.show} icon={<WifiOutlined size={14} />}>
{t('settings.data.export_to_phone.lan.title')}
</Button>
</HStack>
</SettingRow>
<SettingDivider />
</SettingGroup>
<SettingGroup theme={theme}>
<SettingTitle>{t('settings.data.data.title')}</SettingTitle>

View File

@ -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"