This commit is contained in:
槑囿脑袋 2025-12-18 20:33:44 +08:00 committed by GitHub
commit ccb251decd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
44 changed files with 5432 additions and 1199 deletions

View File

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

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",
@ -97,10 +98,8 @@
"node-stream-zip": "^1.15.0",
"officeparser": "^4.2.0",
"os-proxy-config": "^1.1.2",
"qrcode.react": "^4.2.0",
"selection-hook": "^1.0.12",
"sharp": "^0.34.3",
"socket.io": "^4.8.1",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1",
"tesseract.js": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch",

View File

@ -233,6 +233,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',
@ -381,10 +383,14 @@ export enum IpcChannel {
ClaudeCodePlugin_ReadContent = 'claudeCodePlugin:read-content',
ClaudeCodePlugin_WriteContent = 'claudeCodePlugin:write-content',
// WebSocket
WebSocket_Start = 'webSocket:start',
WebSocket_Stop = 'webSocket:stop',
WebSocket_Status = 'webSocket:status',
WebSocket_SendFile = 'webSocket:send-file',
WebSocket_GetAllCandidates = 'webSocket:get-all-candidates'
// Local Transfer
LocalTransfer_ListServices = 'local-transfer:list',
LocalTransfer_StartScan = 'local-transfer:start-scan',
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

@ -52,3 +52,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 (v1)
export const LAN_TRANSFER_PROTOCOL_VERSION = '1'
export const LAN_BINARY_FRAME_MAGIC = 0x4353 // "CS" as uint16
export const LAN_BINARY_TYPE_FILE_CHUNK = 0x01
// 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 v1. 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 v1 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
// 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,8 +19,10 @@ import { agentService } from './services/agents'
import { apiServerService } from './services/ApiServerService'
import { appMenuService } from './services/AppMenuService'
import { configManager } from './services/ConfigManager'
import { nodeTraceService } from './services/NodeTraceService'
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 {
CHERRY_STUDIO_PROTOCOL,
@ -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

@ -18,6 +18,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 {
@ -49,6 +50,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'
@ -80,7 +83,6 @@ import {
import storeSyncService from './services/StoreSyncService'
import { themeService } from './services/ThemeService'
import VertexAIService from './services/VertexAIService'
import WebSocketService from './services/WebSocketService'
import { setOpenLinkExternal } from './services/WebviewService'
import { windowService } from './services/WindowService'
import { calculateDirectorySize, getResourcePath } from './utils'
@ -583,6 +585,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))
@ -1114,12 +1118,17 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
}
})
// WebSocket
ipcMain.handle(IpcChannel.WebSocket_Start, WebSocketService.start)
ipcMain.handle(IpcChannel.WebSocket_Stop, WebSocketService.stop)
ipcMain.handle(IpcChannel.WebSocket_Status, WebSocketService.getStatus)
ipcMain.handle(IpcChannel.WebSocket_SendFile, WebSocketService.sendFile)
ipcMain.handle(IpcChannel.WebSocket_GetAllCandidates, WebSocketService.getAllCandidates)
ipcMain.handle(IpcChannel.LocalTransfer_ListServices, () => localTransferService.getState())
ipcMain.handle(IpcChannel.LocalTransfer_StartScan, () => localTransferService.startDiscovery({ resetList: true }))
ipcMain.handle(IpcChannel.LocalTransfer_StopScan, () => localTransferService.stopDiscovery())
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,56 @@ 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.normalize(path.join(app.getPath('temp'), 'cherry-studio', 'lan-transfer'))
const resolvedPath = path.normalize(path.resolve(filePath))
// Use normalized paths with trailing separator to prevent prefix attacks (e.g., /temp-evil)
if (!resolvedPath.startsWith(tempBase + path.sep) && resolvedPath !== tempBase) {
logger.warn(`[BackupManager] Attempted to delete file outside temp directory: ${filePath}`)
return false
}
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

@ -1,359 +0,0 @@
import { loggerService } from '@logger'
import type { WebSocketCandidatesResponse, WebSocketStatusResponse } from '@shared/config/types'
import * as fs from 'fs'
import { networkInterfaces } from 'os'
import * as path from 'path'
import type { Socket } from 'socket.io'
import { Server } from 'socket.io'
import { windowService } from './WindowService'
const logger = loggerService.withContext('WebSocketService')
class WebSocketService {
private io: Server | null = null
private isStarted = false
private port = 7017
private connectedClients = new Set<string>()
private getLocalIpAddress(): string | undefined {
const interfaces = networkInterfaces()
// 按优先级排序的网络接口名称模式
const interfacePriority = [
// macOS: 以太网/Wi-Fi 优先
/^en[0-9]+$/, // en0, en1 (以太网/Wi-Fi)
/^(en|eth)[0-9]+$/, // 以太网接口
/^wlan[0-9]+$/, // 无线接口
// Windows: 以太网/Wi-Fi 优先
/^(Ethernet|Wi-Fi|Local Area Connection)/,
/^(Wi-Fi|无线网络连接)/,
// Linux: 以太网/Wi-Fi 优先
/^(eth|enp|wlp|wlan)[0-9]+/,
// 虚拟化接口(低优先级)
/^bridge[0-9]+$/, // Docker bridge
/^veth[0-9]+$/, // Docker veth
/^docker[0-9]+/, // Docker interfaces
/^br-[0-9a-f]+/, // Docker bridge
/^vmnet[0-9]+$/, // VMware
/^vboxnet[0-9]+$/, // VirtualBox
// VPN 隧道接口(低优先级)
/^utun[0-9]+$/, // macOS VPN
/^tun[0-9]+$/, // Linux/Unix VPN
/^tap[0-9]+$/, // TAP interfaces
/^tailscale[0-9]*$/, // Tailscale VPN
/^wg[0-9]+$/ // WireGuard VPN
]
const candidates: Array<{ interface: string; address: string; priority: number }> = []
for (const [name, ifaces] of Object.entries(interfaces)) {
for (const iface of ifaces || []) {
if (iface.family === 'IPv4' && !iface.internal) {
// 计算接口优先级
let priority = 999 // 默认最低优先级
for (let i = 0; i < interfacePriority.length; i++) {
if (interfacePriority[i].test(name)) {
priority = i
break
}
}
candidates.push({
interface: name,
address: iface.address,
priority
})
}
}
}
if (candidates.length === 0) {
logger.warn('无法获取局域网 IP使用默认 IP: 127.0.0.1')
return '127.0.0.1'
}
// 按优先级排序,选择优先级最高的
candidates.sort((a, b) => a.priority - b.priority)
const best = candidates[0]
logger.info(`获取局域网 IP: ${best.address} (interface: ${best.interface})`)
return best.address
}
public start = async (): Promise<{ success: boolean; port?: number; error?: string }> => {
if (this.isStarted && this.io) {
return { success: true, port: this.port }
}
try {
this.io = new Server(this.port, {
cors: {
origin: '*',
methods: ['GET', 'POST']
},
transports: ['websocket', 'polling'],
allowEIO3: true,
pingTimeout: 60000,
pingInterval: 25000
})
this.io.on('connection', (socket: Socket) => {
this.connectedClients.add(socket.id)
const mainWindow = windowService.getMainWindow()
if (!mainWindow) {
logger.error('Main window is null, cannot send connection event')
} else {
mainWindow.webContents.send('websocket-client-connected', {
connected: true,
clientId: socket.id
})
logger.info(`Connection event sent to renderer, total clients: ${this.connectedClients.size}`)
}
socket.on('message', (data) => {
logger.info('Received message from mobile:', data)
mainWindow?.webContents.send('websocket-message-received', data)
socket.emit('message_received', { success: true })
})
socket.on('disconnect', () => {
logger.info(`Client disconnected: ${socket.id}`)
this.connectedClients.delete(socket.id)
if (this.connectedClients.size === 0) {
mainWindow?.webContents.send('websocket-client-connected', {
connected: false,
clientId: socket.id
})
}
})
})
// Engine 层面的事件监听
this.io.engine.on('connection_error', (err) => {
logger.error('Engine connection error:', err)
})
this.io.engine.on('connection', (rawSocket) => {
const remoteAddr = rawSocket.request.connection.remoteAddress
logger.info(`[Engine] Raw connection from: ${remoteAddr}`)
logger.info(`[Engine] Transport: ${rawSocket.transport.name}`)
rawSocket.on('packet', (packet: { type: string; data?: any }) => {
logger.info(
`[Engine] ← Packet from ${remoteAddr}: type="${packet.type}"`,
packet.data ? { data: packet.data } : {}
)
})
rawSocket.on('packetCreate', (packet: { type: string; data?: any }) => {
logger.info(`[Engine] → Packet to ${remoteAddr}: type="${packet.type}"`)
})
rawSocket.on('close', (reason: string) => {
logger.warn(`[Engine] Connection closed from ${remoteAddr}, reason: ${reason}`)
})
rawSocket.on('error', (error: Error) => {
logger.error(`[Engine] Connection error from ${remoteAddr}:`, error)
})
})
// Socket.IO 握手失败监听
this.io.on('connection_error', (err) => {
logger.error('[Socket.IO] Connection error during handshake:', err)
})
this.isStarted = true
logger.info(`WebSocket server started on port ${this.port}`)
return { success: true, port: this.port }
} catch (error) {
logger.error('Failed to start WebSocket server:', error as Error)
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
}
}
}
public stop = async (): Promise<{ success: boolean }> => {
if (!this.isStarted || !this.io) {
return { success: true }
}
try {
await new Promise<void>((resolve) => {
this.io!.close(() => {
resolve()
})
})
this.io = null
this.isStarted = false
this.connectedClients.clear()
logger.info('WebSocket server stopped')
return { success: true }
} catch (error) {
logger.error('Failed to stop WebSocket server:', error as Error)
return { success: false }
}
}
public getStatus = async (): Promise<WebSocketStatusResponse> => {
return {
isRunning: this.isStarted,
port: this.isStarted ? this.port : undefined,
ip: this.isStarted ? this.getLocalIpAddress() : undefined,
clientConnected: this.connectedClients.size > 0
}
}
public getAllCandidates = async (): Promise<WebSocketCandidatesResponse[]> => {
const interfaces = networkInterfaces()
// 按优先级排序的网络接口名称模式
const interfacePriority = [
// macOS: 以太网/Wi-Fi 优先
/^en[0-9]+$/, // en0, en1 (以太网/Wi-Fi)
/^(en|eth)[0-9]+$/, // 以太网接口
/^wlan[0-9]+$/, // 无线接口
// Windows: 以太网/Wi-Fi 优先
/^(Ethernet|Wi-Fi|Local Area Connection)/,
/^(Wi-Fi|无线网络连接)/,
// Linux: 以太网/Wi-Fi 优先
/^(eth|enp|wlp|wlan)[0-9]+/,
// 虚拟化接口(低优先级)
/^bridge[0-9]+$/, // Docker bridge
/^veth[0-9]+$/, // Docker veth
/^docker[0-9]+/, // Docker interfaces
/^br-[0-9a-f]+/, // Docker bridge
/^vmnet[0-9]+$/, // VMware
/^vboxnet[0-9]+$/, // VirtualBox
// VPN 隧道接口(低优先级)
/^utun[0-9]+$/, // macOS VPN
/^tun[0-9]+$/, // Linux/Unix VPN
/^tap[0-9]+$/, // TAP interfaces
/^tailscale[0-9]*$/, // Tailscale VPN
/^wg[0-9]+$/ // WireGuard VPN
]
const candidates: Array<{ host: string; interface: string; priority: number }> = []
for (const [name, ifaces] of Object.entries(interfaces)) {
for (const iface of ifaces || []) {
if (iface.family === 'IPv4' && !iface.internal) {
// 计算接口优先级
let priority = 999 // 默认最低优先级
for (let i = 0; i < interfacePriority.length; i++) {
if (interfacePriority[i].test(name)) {
priority = i
break
}
}
candidates.push({
host: iface.address,
interface: name,
priority
})
logger.debug(`Found interface: ${name} -> ${iface.address} (priority: ${priority})`)
}
}
}
// 按优先级排序返回
candidates.sort((a, b) => a.priority - b.priority)
logger.info(
`Found ${candidates.length} IP candidates: ${candidates.map((c) => `${c.host}(${c.interface})`).join(', ')}`
)
return candidates
}
public sendFile = async (
_: Electron.IpcMainInvokeEvent,
filePath: string
): Promise<{ success: boolean; error?: string }> => {
if (!this.isStarted || !this.io) {
const errorMsg = 'WebSocket server is not running.'
logger.error(errorMsg)
return { success: false, error: errorMsg }
}
if (this.connectedClients.size === 0) {
const errorMsg = 'No client connected.'
logger.error(errorMsg)
return { success: false, error: errorMsg }
}
const mainWindow = windowService.getMainWindow()
return new Promise((resolve, reject) => {
const stats = fs.statSync(filePath)
const totalSize = stats.size
const filename = path.basename(filePath)
const stream = fs.createReadStream(filePath)
let bytesSent = 0
const startTime = Date.now()
logger.info(`Starting file transfer: ${filename} (${this.formatFileSize(totalSize)})`)
// 向客户端发送文件开始的信号,包含文件名和总大小
this.io!.emit('zip-file-start', { filename, totalSize })
stream.on('data', (chunk) => {
bytesSent += chunk.length
const progress = (bytesSent / totalSize) * 100
// 向客户端发送文件块
this.io!.emit('zip-file-chunk', chunk)
// 向渲染进程发送进度更新
mainWindow?.webContents.send('file-send-progress', { progress })
// 每10%记录一次进度
if (Math.floor(progress) % 10 === 0) {
const elapsed = (Date.now() - startTime) / 1000
const speed = elapsed > 0 ? bytesSent / elapsed : 0
logger.info(`Transfer progress: ${Math.floor(progress)}% (${this.formatFileSize(speed)}/s)`)
}
})
stream.on('end', () => {
const totalTime = (Date.now() - startTime) / 1000
const avgSpeed = totalTime > 0 ? totalSize / totalTime : 0
logger.info(
`File transfer completed: ${filename} in ${totalTime.toFixed(1)}s (${this.formatFileSize(avgSpeed)}/s)`
)
// 确保发送100%的进度
mainWindow?.webContents.send('file-send-progress', { progress: 100 })
// 向客户端发送文件结束的信号
this.io!.emit('zip-file-end')
resolve({ success: true })
})
stream.on('error', (error) => {
logger.error(`File transfer failed: ${filename}`, error)
reject({
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
})
})
})
}
private formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
}
export default new WebSocketService()

View File

@ -0,0 +1,274 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
// Use vi.hoisted to define mocks that are available during hoisting
const { mockLogger } = vi.hoisted(() => ({
mockLogger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn()
}
}))
vi.mock('@logger', () => ({
loggerService: {
withContext: () => mockLogger
}
}))
vi.mock('electron', () => ({
app: {
getPath: vi.fn((key: string) => {
if (key === 'temp') return '/tmp'
if (key === 'userData') return '/mock/userData'
return '/mock/unknown'
})
}
}))
vi.mock('fs-extra', () => ({
default: {
pathExists: vi.fn(),
remove: vi.fn(),
ensureDir: vi.fn(),
copy: vi.fn(),
readdir: vi.fn(),
stat: vi.fn(),
readFile: vi.fn(),
writeFile: vi.fn(),
createWriteStream: vi.fn(),
createReadStream: vi.fn()
},
pathExists: vi.fn(),
remove: vi.fn(),
ensureDir: vi.fn(),
copy: vi.fn(),
readdir: vi.fn(),
stat: vi.fn(),
readFile: vi.fn(),
writeFile: vi.fn(),
createWriteStream: vi.fn(),
createReadStream: vi.fn()
}))
vi.mock('../WindowService', () => ({
windowService: {
getMainWindow: vi.fn()
}
}))
vi.mock('../WebDav', () => ({
default: vi.fn()
}))
vi.mock('../S3Storage', () => ({
default: vi.fn()
}))
vi.mock('../../utils', () => ({
getDataPath: vi.fn(() => '/mock/data')
}))
vi.mock('archiver', () => ({
default: vi.fn()
}))
vi.mock('node-stream-zip', () => ({
default: vi.fn()
}))
// Import after mocks
import * as fs from 'fs-extra'
import BackupManager from '../BackupManager'
describe('BackupManager.deleteTempBackup - Security Tests', () => {
let backupManager: BackupManager
beforeEach(() => {
vi.clearAllMocks()
backupManager = new BackupManager()
})
describe('Normal Operations', () => {
it('should delete valid file in allowed directory', async () => {
vi.mocked(fs.pathExists).mockResolvedValue(true as never)
vi.mocked(fs.remove).mockResolvedValue(undefined as never)
const validPath = '/tmp/cherry-studio/lan-transfer/backup.zip'
const result = await backupManager.deleteTempBackup({} as Electron.IpcMainInvokeEvent, validPath)
expect(result).toBe(true)
expect(fs.remove).toHaveBeenCalledWith(validPath)
expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining('Deleted temp backup'))
})
it('should delete file in nested subdirectory', async () => {
vi.mocked(fs.pathExists).mockResolvedValue(true as never)
vi.mocked(fs.remove).mockResolvedValue(undefined as never)
const nestedPath = '/tmp/cherry-studio/lan-transfer/sub/dir/file.zip'
const result = await backupManager.deleteTempBackup({} as Electron.IpcMainInvokeEvent, nestedPath)
expect(result).toBe(true)
expect(fs.remove).toHaveBeenCalledWith(nestedPath)
})
it('should return false when file does not exist', async () => {
vi.mocked(fs.pathExists).mockResolvedValue(false as never)
const missingPath = '/tmp/cherry-studio/lan-transfer/missing.zip'
const result = await backupManager.deleteTempBackup({} as Electron.IpcMainInvokeEvent, missingPath)
expect(result).toBe(false)
expect(fs.remove).not.toHaveBeenCalled()
})
})
describe('Path Traversal Attacks', () => {
it('should block basic directory traversal attack (../../../../etc/passwd)', async () => {
const attackPath = '/tmp/cherry-studio/lan-transfer/../../../../etc/passwd'
const result = await backupManager.deleteTempBackup({} as Electron.IpcMainInvokeEvent, attackPath)
expect(result).toBe(false)
expect(fs.pathExists).not.toHaveBeenCalled()
expect(fs.remove).not.toHaveBeenCalled()
expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('outside temp directory'))
})
it('should block absolute path escape (/etc/passwd)', async () => {
const attackPath = '/etc/passwd'
const result = await backupManager.deleteTempBackup({} as Electron.IpcMainInvokeEvent, attackPath)
expect(result).toBe(false)
expect(fs.remove).not.toHaveBeenCalled()
expect(mockLogger.warn).toHaveBeenCalled()
})
it('should block traversal with multiple slashes', async () => {
const attackPath = '/tmp/cherry-studio/lan-transfer/../../../etc/passwd'
const result = await backupManager.deleteTempBackup({} as Electron.IpcMainInvokeEvent, attackPath)
expect(result).toBe(false)
expect(fs.remove).not.toHaveBeenCalled()
})
it('should block relative path traversal from current directory', async () => {
const attackPath = '../../../etc/passwd'
const result = await backupManager.deleteTempBackup({} as Electron.IpcMainInvokeEvent, attackPath)
expect(result).toBe(false)
expect(fs.remove).not.toHaveBeenCalled()
})
it('should block traversal to parent directory', async () => {
const attackPath = '/tmp/cherry-studio/lan-transfer/../backup/secret.zip'
const result = await backupManager.deleteTempBackup({} as Electron.IpcMainInvokeEvent, attackPath)
expect(result).toBe(false)
expect(fs.remove).not.toHaveBeenCalled()
})
})
describe('Prefix Attacks', () => {
it('should block similar prefix attack (lan-transfer-evil)', async () => {
const attackPath = '/tmp/cherry-studio/lan-transfer-evil/file.zip'
const result = await backupManager.deleteTempBackup({} as Electron.IpcMainInvokeEvent, attackPath)
expect(result).toBe(false)
expect(fs.remove).not.toHaveBeenCalled()
expect(mockLogger.warn).toHaveBeenCalled()
})
it('should block path without separator (lan-transferx)', async () => {
const attackPath = '/tmp/cherry-studio/lan-transferx'
const result = await backupManager.deleteTempBackup({} as Electron.IpcMainInvokeEvent, attackPath)
expect(result).toBe(false)
expect(fs.remove).not.toHaveBeenCalled()
})
it('should block different temp directory prefix', async () => {
const attackPath = '/tmp-evil/cherry-studio/lan-transfer/file.zip'
const result = await backupManager.deleteTempBackup({} as Electron.IpcMainInvokeEvent, attackPath)
expect(result).toBe(false)
expect(fs.remove).not.toHaveBeenCalled()
})
})
describe('Error Handling', () => {
it('should return false and log error on permission denied', async () => {
vi.mocked(fs.pathExists).mockResolvedValue(true as never)
vi.mocked(fs.remove).mockRejectedValue(new Error('EACCES: permission denied') as never)
const validPath = '/tmp/cherry-studio/lan-transfer/file.zip'
const result = await backupManager.deleteTempBackup({} as Electron.IpcMainInvokeEvent, validPath)
expect(result).toBe(false)
expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining('Failed to delete'), expect.any(Error))
})
it('should return false on fs.pathExists error', async () => {
vi.mocked(fs.pathExists).mockRejectedValue(new Error('ENOENT') as never)
const validPath = '/tmp/cherry-studio/lan-transfer/file.zip'
const result = await backupManager.deleteTempBackup({} as Electron.IpcMainInvokeEvent, validPath)
expect(result).toBe(false)
expect(mockLogger.error).toHaveBeenCalled()
})
it('should handle empty path string', async () => {
const result = await backupManager.deleteTempBackup({} as Electron.IpcMainInvokeEvent, '')
expect(result).toBe(false)
expect(fs.remove).not.toHaveBeenCalled()
})
})
describe('Edge Cases', () => {
it('should allow deletion of the temp directory itself', async () => {
vi.mocked(fs.pathExists).mockResolvedValue(true as never)
vi.mocked(fs.remove).mockResolvedValue(undefined as never)
const tempDir = '/tmp/cherry-studio/lan-transfer'
const result = await backupManager.deleteTempBackup({} as Electron.IpcMainInvokeEvent, tempDir)
expect(result).toBe(true)
expect(fs.remove).toHaveBeenCalledWith(tempDir)
})
it('should handle path with trailing slash', async () => {
vi.mocked(fs.pathExists).mockResolvedValue(true as never)
vi.mocked(fs.remove).mockResolvedValue(undefined as never)
const pathWithSlash = '/tmp/cherry-studio/lan-transfer/sub/'
const result = await backupManager.deleteTempBackup({} as Electron.IpcMainInvokeEvent, pathWithSlash)
// path.normalize removes trailing slash
expect(result).toBe(true)
})
it('should handle file with special characters in name', async () => {
vi.mocked(fs.pathExists).mockResolvedValue(true as never)
vi.mocked(fs.remove).mockResolvedValue(undefined as never)
const specialPath = '/tmp/cherry-studio/lan-transfer/file with spaces & (special).zip'
const result = await backupManager.deleteTempBackup({} as Electron.IpcMainInvokeEvent, specialPath)
expect(result).toBe(true)
expect(fs.remove).toHaveBeenCalled()
})
it('should handle path with double slashes', async () => {
vi.mocked(fs.pathExists).mockResolvedValue(true as never)
vi.mocked(fs.remove).mockResolvedValue(undefined as never)
const doubleSlashPath = '/tmp/cherry-studio//lan-transfer//file.zip'
const result = await backupManager.deleteTempBackup({} as Electron.IpcMainInvokeEvent, doubleSlashPath)
// path.normalize handles double slashes
expect(result).toBe(true)
})
})
})

View File

@ -0,0 +1,481 @@
import { EventEmitter } from 'events'
import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
// Create mock objects before vi.mock calls
const mockLogger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn()
}
let mockMainWindow: {
isDestroyed: Mock
webContents: { send: Mock }
} | null = null
let mockBrowser: EventEmitter & {
start: Mock
stop: Mock
removeAllListeners: Mock
}
let mockBonjour: {
find: Mock
destroy: Mock
}
// Mock dependencies before importing the service
vi.mock('@logger', () => ({
loggerService: {
withContext: () => mockLogger
}
}))
vi.mock('../WindowService', () => ({
windowService: {
getMainWindow: vi.fn(() => mockMainWindow)
}
}))
vi.mock('bonjour-service', () => ({
default: vi.fn(() => mockBonjour)
}))
describe('LocalTransferService', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.resetModules()
// Reset mock objects
mockMainWindow = {
isDestroyed: vi.fn(() => false),
webContents: { send: vi.fn() }
}
mockBrowser = Object.assign(new EventEmitter(), {
start: vi.fn(),
stop: vi.fn(),
removeAllListeners: vi.fn()
})
mockBonjour = {
find: vi.fn(() => mockBrowser),
destroy: vi.fn()
}
})
afterEach(() => {
vi.resetAllMocks()
})
describe('startDiscovery', () => {
it('should set isScanning to true and start browser', async () => {
const { localTransferService } = await import('../LocalTransferService')
const state = localTransferService.startDiscovery()
expect(state.isScanning).toBe(true)
expect(state.lastScanStartedAt).toBeDefined()
expect(mockBonjour.find).toHaveBeenCalledWith({ type: 'cherrystudio', protocol: 'tcp' })
expect(mockBrowser.start).toHaveBeenCalled()
})
it('should clear services when resetList is true', async () => {
const { localTransferService } = await import('../LocalTransferService')
// First, start discovery and add a service
localTransferService.startDiscovery()
mockBrowser.emit('up', {
name: 'Test Service',
host: 'localhost',
port: 12345,
addresses: ['192.168.1.100'],
fqdn: 'test.local'
})
expect(localTransferService.getState().services).toHaveLength(1)
// Now restart with resetList
const state = localTransferService.startDiscovery({ resetList: true })
expect(state.services).toHaveLength(0)
})
it('should broadcast state after starting discovery', async () => {
const { localTransferService } = await import('../LocalTransferService')
localTransferService.startDiscovery()
expect(mockMainWindow?.webContents.send).toHaveBeenCalled()
})
it('should handle browser.start() error', async () => {
mockBrowser.start.mockImplementation(() => {
throw new Error('Failed to start mDNS')
})
const { localTransferService } = await import('../LocalTransferService')
const state = localTransferService.startDiscovery()
expect(state.lastError).toBe('Failed to start mDNS')
expect(mockLogger.error).toHaveBeenCalled()
})
})
describe('stopDiscovery', () => {
it('should set isScanning to false and stop browser', async () => {
const { localTransferService } = await import('../LocalTransferService')
localTransferService.startDiscovery()
const state = localTransferService.stopDiscovery()
expect(state.isScanning).toBe(false)
expect(mockBrowser.stop).toHaveBeenCalled()
})
it('should handle browser.stop() error gracefully', async () => {
mockBrowser.stop.mockImplementation(() => {
throw new Error('Stop failed')
})
const { localTransferService } = await import('../LocalTransferService')
localTransferService.startDiscovery()
// Should not throw
expect(() => localTransferService.stopDiscovery()).not.toThrow()
expect(mockLogger.warn).toHaveBeenCalled()
})
it('should broadcast state after stopping', async () => {
const { localTransferService } = await import('../LocalTransferService')
localTransferService.startDiscovery()
vi.clearAllMocks()
localTransferService.stopDiscovery()
expect(mockMainWindow?.webContents.send).toHaveBeenCalled()
})
})
describe('browser events', () => {
it('should add service on "up" event', async () => {
const { localTransferService } = await import('../LocalTransferService')
localTransferService.startDiscovery()
mockBrowser.emit('up', {
name: 'Test Service',
host: 'localhost',
port: 12345,
addresses: ['192.168.1.100'],
fqdn: 'test.local',
type: 'cherrystudio',
protocol: 'tcp'
})
const state = localTransferService.getState()
expect(state.services).toHaveLength(1)
expect(state.services[0].name).toBe('Test Service')
expect(state.services[0].port).toBe(12345)
expect(state.services[0].addresses).toContain('192.168.1.100')
})
it('should remove service on "down" event', async () => {
const { localTransferService } = await import('../LocalTransferService')
localTransferService.startDiscovery()
// Add service
mockBrowser.emit('up', {
name: 'Test Service',
host: 'localhost',
port: 12345,
addresses: ['192.168.1.100'],
fqdn: 'test.local'
})
expect(localTransferService.getState().services).toHaveLength(1)
// Remove service
mockBrowser.emit('down', {
name: 'Test Service',
host: 'localhost',
port: 12345,
fqdn: 'test.local'
})
expect(localTransferService.getState().services).toHaveLength(0)
expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining('removed'))
})
it('should set lastError on "error" event', async () => {
const { localTransferService } = await import('../LocalTransferService')
localTransferService.startDiscovery()
mockBrowser.emit('error', new Error('Discovery failed'))
const state = localTransferService.getState()
expect(state.lastError).toBe('Discovery failed')
expect(mockLogger.error).toHaveBeenCalled()
})
it('should handle non-Error objects in error event', async () => {
const { localTransferService } = await import('../LocalTransferService')
localTransferService.startDiscovery()
mockBrowser.emit('error', 'String error message')
const state = localTransferService.getState()
expect(state.lastError).toBe('String error message')
})
})
describe('getState', () => {
it('should return sorted services by name', async () => {
const { localTransferService } = await import('../LocalTransferService')
localTransferService.startDiscovery()
mockBrowser.emit('up', {
name: 'Zebra Service',
host: 'host1',
port: 1001,
addresses: ['192.168.1.1']
})
mockBrowser.emit('up', {
name: 'Alpha Service',
host: 'host2',
port: 1002,
addresses: ['192.168.1.2']
})
const state = localTransferService.getState()
expect(state.services[0].name).toBe('Alpha Service')
expect(state.services[1].name).toBe('Zebra Service')
})
it('should include all state properties', async () => {
const { localTransferService } = await import('../LocalTransferService')
localTransferService.startDiscovery()
const state = localTransferService.getState()
expect(state).toHaveProperty('services')
expect(state).toHaveProperty('isScanning')
expect(state).toHaveProperty('lastScanStartedAt')
expect(state).toHaveProperty('lastUpdatedAt')
})
})
describe('getPeerById', () => {
it('should return peer when exists', async () => {
const { localTransferService } = await import('../LocalTransferService')
localTransferService.startDiscovery()
mockBrowser.emit('up', {
name: 'Test Service',
host: 'localhost',
port: 12345,
addresses: ['192.168.1.100'],
fqdn: 'test.local'
})
const services = localTransferService.getState().services
const peer = localTransferService.getPeerById(services[0].id)
expect(peer).toBeDefined()
expect(peer?.name).toBe('Test Service')
})
it('should return undefined when peer does not exist', async () => {
const { localTransferService } = await import('../LocalTransferService')
const peer = localTransferService.getPeerById('non-existent-id')
expect(peer).toBeUndefined()
})
})
describe('normalizeService', () => {
it('should deduplicate addresses', async () => {
const { localTransferService } = await import('../LocalTransferService')
localTransferService.startDiscovery()
mockBrowser.emit('up', {
name: 'Test Service',
host: 'localhost',
port: 12345,
addresses: ['192.168.1.100', '192.168.1.100', '10.0.0.1'],
referer: { address: '192.168.1.100' }
})
const services = localTransferService.getState().services
expect(services[0].addresses).toHaveLength(2)
expect(services[0].addresses).toContain('192.168.1.100')
expect(services[0].addresses).toContain('10.0.0.1')
})
it('should filter empty addresses', async () => {
const { localTransferService } = await import('../LocalTransferService')
localTransferService.startDiscovery()
mockBrowser.emit('up', {
name: 'Test Service',
host: 'localhost',
port: 12345,
addresses: ['192.168.1.100', '', null as any]
})
const services = localTransferService.getState().services
expect(services[0].addresses).toEqual(['192.168.1.100'])
})
it('should convert txt null/undefined values to empty strings', async () => {
const { localTransferService } = await import('../LocalTransferService')
localTransferService.startDiscovery()
mockBrowser.emit('up', {
name: 'Test Service',
host: 'localhost',
port: 12345,
addresses: ['192.168.1.100'],
txt: {
version: '1.0',
nullValue: null,
undefinedValue: undefined,
numberValue: 42
}
})
const services = localTransferService.getState().services
expect(services[0].txt).toEqual({
version: '1.0',
nullValue: '',
undefinedValue: '',
numberValue: '42'
})
})
it('should not include txt when empty', async () => {
const { localTransferService } = await import('../LocalTransferService')
localTransferService.startDiscovery()
mockBrowser.emit('up', {
name: 'Test Service',
host: 'localhost',
port: 12345,
addresses: ['192.168.1.100'],
txt: {}
})
const services = localTransferService.getState().services
expect(services[0].txt).toBeUndefined()
})
})
describe('dispose', () => {
it('should clean up all resources', async () => {
const { localTransferService } = await import('../LocalTransferService')
localTransferService.startDiscovery()
mockBrowser.emit('up', {
name: 'Test Service',
host: 'localhost',
port: 12345,
addresses: ['192.168.1.100']
})
localTransferService.dispose()
expect(localTransferService.getState().services).toHaveLength(0)
expect(localTransferService.getState().isScanning).toBe(false)
expect(mockBrowser.removeAllListeners).toHaveBeenCalled()
expect(mockBonjour.destroy).toHaveBeenCalled()
})
it('should handle bonjour.destroy() error gracefully', async () => {
mockBonjour.destroy.mockImplementation(() => {
throw new Error('Destroy failed')
})
const { localTransferService } = await import('../LocalTransferService')
localTransferService.startDiscovery()
// Should not throw
expect(() => localTransferService.dispose()).not.toThrow()
expect(mockLogger.warn).toHaveBeenCalled()
})
it('should be safe to call multiple times', async () => {
const { localTransferService } = await import('../LocalTransferService')
localTransferService.startDiscovery()
expect(() => {
localTransferService.dispose()
localTransferService.dispose()
}).not.toThrow()
})
})
describe('broadcastState', () => {
it('should not throw when main window is null', async () => {
mockMainWindow = null
const { localTransferService } = await import('../LocalTransferService')
// Should not throw
expect(() => localTransferService.startDiscovery()).not.toThrow()
})
it('should not throw when main window is destroyed', async () => {
mockMainWindow = {
isDestroyed: vi.fn(() => true),
webContents: { send: vi.fn() }
}
const { localTransferService } = await import('../LocalTransferService')
// Should not throw
expect(() => localTransferService.startDiscovery()).not.toThrow()
expect(mockMainWindow.webContents.send).not.toHaveBeenCalled()
})
})
describe('restartBrowser', () => {
it('should destroy old bonjour instance to prevent socket leaks', async () => {
const { localTransferService } = await import('../LocalTransferService')
// First start
localTransferService.startDiscovery()
expect(mockBonjour.destroy).not.toHaveBeenCalled()
// Restart - should destroy old instance
localTransferService.startDiscovery()
expect(mockBonjour.destroy).toHaveBeenCalled()
})
it('should remove all listeners from old browser', async () => {
const { localTransferService } = await import('../LocalTransferService')
localTransferService.startDiscovery()
localTransferService.startDiscovery()
expect(mockBrowser.removeAllListeners).toHaveBeenCalled()
})
})
})

View File

@ -0,0 +1,525 @@
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 v1 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
private consecutiveJsonErrors = 0
private static readonly MAX_CONSECUTIVE_JSON_ERRORS = 3
private reconnectPromise: Promise<void> | null = null
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 (error) {
// Expected when connection is already broken
logger.warn('Failed to send cancel message', error as Error)
}
abortTransfer(this.activeTransfer, new Error('Transfer cancelled by user'))
}
// =============================================================================
// 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.')
}
// Prevent concurrent reconnection attempts
if (this.reconnectPromise) {
logger.debug('Waiting for existing reconnection attempt...')
await this.reconnectPromise
return
}
logger.info('Connection lost, attempting to reconnect...')
this.reconnectPromise = this.connectAndHandshake(this.lastConnectOptions)
.then(() => {
// Handshake succeeded, connection restored
})
.finally(() => {
this.reconnectPromise = null
})
await this.reconnectPromise
}
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) => {
try {
this.dataHandler?.handleData(chunk)
} catch (error) {
logger.error('Data handler error', error as Error)
void this.disconnect()
}
})
}
private handleControlLine(line: string): void {
let payload: Record<string, unknown>
try {
payload = JSON.parse(line)
this.consecutiveJsonErrors = 0 // Reset on successful parse
} catch {
this.consecutiveJsonErrors++
logger.warn('Received invalid JSON control message', { line, consecutiveErrors: this.consecutiveJsonErrors })
if (this.consecutiveJsonErrors >= LanTransferClientService.MAX_CONSECUTIVE_JSON_ERRORS) {
const message = `Protocol error: ${this.consecutiveJsonErrors} consecutive invalid messages, disconnecting`
logger.error(message)
this.broadcastClientEvent({
type: 'error',
message,
timestamp: Date.now()
})
void this.disconnect()
}
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,133 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// Mock dependencies before importing the service
vi.mock('node:net', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>
return {
...actual,
createConnection: vi.fn()
}
})
vi.mock('electron', () => ({
app: {
getName: vi.fn(() => 'Cherry Studio'),
getVersion: vi.fn(() => '1.0.0')
}
}))
vi.mock('../../LocalTransferService', () => ({
localTransferService: {
getPeerById: vi.fn()
}
}))
vi.mock('../../WindowService', () => ({
windowService: {
getMainWindow: vi.fn(() => ({
isDestroyed: () => false,
webContents: {
send: vi.fn()
}
}))
}
}))
// Import after mocks
import { localTransferService } from '../../LocalTransferService'
describe('LanTransferClientService', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.resetModules()
})
afterEach(() => {
vi.resetAllMocks()
})
describe('connectAndHandshake - validation', () => {
it('should throw error when peer is not found', async () => {
vi.mocked(localTransferService.getPeerById).mockReturnValue(undefined)
const { lanTransferClientService } = await import('../LanTransferClientService')
await expect(
lanTransferClientService.connectAndHandshake({
peerId: 'non-existent'
})
).rejects.toThrow('Selected LAN peer is no longer available')
})
it('should throw error when peer has no port', async () => {
vi.mocked(localTransferService.getPeerById).mockReturnValue({
id: 'test-peer',
name: 'Test Peer',
addresses: ['192.168.1.100'],
updatedAt: Date.now()
})
const { lanTransferClientService } = await import('../LanTransferClientService')
await expect(
lanTransferClientService.connectAndHandshake({
peerId: 'test-peer'
})
).rejects.toThrow('Selected peer does not expose a TCP port')
})
it('should throw error when no reachable host', async () => {
vi.mocked(localTransferService.getPeerById).mockReturnValue({
id: 'test-peer',
name: 'Test Peer',
port: 12345,
addresses: [],
updatedAt: Date.now()
})
const { lanTransferClientService } = await import('../LanTransferClientService')
await expect(
lanTransferClientService.connectAndHandshake({
peerId: 'test-peer'
})
).rejects.toThrow('Unable to resolve a reachable host for the peer')
})
})
describe('cancelTransfer', () => {
it('should not throw when no active transfer', async () => {
const { lanTransferClientService } = await import('../LanTransferClientService')
// Should not throw, just log warning
expect(() => lanTransferClientService.cancelTransfer()).not.toThrow()
})
})
describe('dispose', () => {
it('should clean up resources without throwing', async () => {
const { lanTransferClientService } = await import('../LanTransferClientService')
// Should not throw
expect(() => lanTransferClientService.dispose()).not.toThrow()
})
})
describe('sendFile', () => {
it('should throw error when not connected', async () => {
const { lanTransferClientService } = await import('../LanTransferClientService')
await expect(lanTransferClientService.sendFile('/path/to/file.zip')).rejects.toThrow(
'No active connection. Please connect to a peer first.'
)
})
})
describe('HANDSHAKE_PROTOCOL_VERSION', () => {
it('should export protocol version', async () => {
const { HANDSHAKE_PROTOCOL_VERSION } = await import('../LanTransferClientService')
expect(HANDSHAKE_PROTOCOL_VERSION).toBe('1')
})
})
})

View File

@ -0,0 +1,103 @@
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(), {
destroyed: false,
writable: true,
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)
})
it('should throw error when socket is not writable', () => {
;(mockSocket as any).writable = false
expect(() => sendBinaryChunk(mockSocket, 'test-id', 0, Buffer.from('data'))).toThrow('Socket is not writable')
})
it('should throw error when socket is destroyed', () => {
;(mockSocket as any).destroyed = true
expect(() => sendBinaryChunk(mockSocket, 'test-id', 0, Buffer.from('data'))).toThrow('Socket is not writable')
})
})
describe('BINARY_TYPE_FILE_CHUNK', () => {
it('should be 0x01', () => {
expect(BINARY_TYPE_FILE_CHUNK).toBe(0x01)
})
})
})

View File

@ -0,0 +1,265 @@
import { EventEmitter } from 'node:events'
import type { Socket } from 'node:net'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
buildHandshakeMessage,
createDataHandler,
getAbortError,
HANDSHAKE_PROTOCOL_VERSION,
pickHost,
waitForSocketDrain
} 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 1', () => {
expect(HANDSHAKE_PROTOCOL_VERSION).toBe('1')
})
})
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"}'])
})
it('should throw error when buffer exceeds MAX_LINE_BUFFER_SIZE', () => {
const handler = createDataHandler(vi.fn())
// Create a buffer larger than 1MB (MAX_LINE_BUFFER_SIZE)
const largeData = 'x'.repeat(1024 * 1024 + 1)
expect(() => handler.handleData(Buffer.from(largeData))).toThrow('Control message too large')
})
it('should reset buffer after exceeding MAX_LINE_BUFFER_SIZE', () => {
const lines: string[] = []
const handler = createDataHandler((line) => lines.push(line))
// Create a buffer larger than 1MB
const largeData = 'x'.repeat(1024 * 1024 + 1)
try {
handler.handleData(Buffer.from(largeData))
} catch {
// Expected error
}
// Buffer should be reset, so lineBuffer should be empty
expect(handler.lineBuffer).toBe('')
})
})
describe('waitForSocketDrain', () => {
let mockSocket: Socket & EventEmitter
beforeEach(() => {
mockSocket = Object.assign(new EventEmitter(), {
destroyed: false,
writable: true,
write: vi.fn(),
off: vi.fn(),
removeAllListeners: vi.fn()
}) as unknown as Socket & EventEmitter
})
afterEach(() => {
vi.resetAllMocks()
})
it('should throw error when abort signal is already aborted', async () => {
const abortController = new AbortController()
abortController.abort(new Error('Already aborted'))
await expect(waitForSocketDrain(mockSocket, abortController.signal)).rejects.toThrow('Already aborted')
})
it('should throw error when socket is destroyed', async () => {
;(mockSocket as any).destroyed = true
const abortController = new AbortController()
await expect(waitForSocketDrain(mockSocket, abortController.signal)).rejects.toThrow('Socket is closed')
})
it('should resolve when drain event is emitted', async () => {
const abortController = new AbortController()
const drainPromise = waitForSocketDrain(mockSocket, abortController.signal)
// Emit drain event after a short delay
setImmediate(() => mockSocket.emit('drain'))
await expect(drainPromise).resolves.toBeUndefined()
})
it('should reject when close event is emitted', async () => {
const abortController = new AbortController()
const drainPromise = waitForSocketDrain(mockSocket, abortController.signal)
setImmediate(() => mockSocket.emit('close'))
await expect(drainPromise).rejects.toThrow('Socket closed while waiting for drain')
})
it('should reject when error event is emitted', async () => {
const abortController = new AbortController()
const drainPromise = waitForSocketDrain(mockSocket, abortController.signal)
setImmediate(() => mockSocket.emit('error', new Error('Network error')))
await expect(drainPromise).rejects.toThrow('Network error')
})
it('should reject when abort signal is triggered', async () => {
const abortController = new AbortController()
const drainPromise = waitForSocketDrain(mockSocket, abortController.signal)
setImmediate(() => abortController.abort(new Error('User cancelled')))
await expect(drainPromise).rejects.toThrow('User cancelled')
})
})
describe('getAbortError', () => {
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,216 @@
import { EventEmitter } from 'node:events'
import type * as fs from 'node:fs'
import type { Socket } from 'node:net'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
abortTransfer,
cleanupTransfer,
createTransferState,
formatFileSize,
streamFileChunks
} from '../../handlers/fileTransfer'
import type { ActiveFileTransfer } from '../../types'
// Mock binaryProtocol
vi.mock('../../binaryProtocol', () => ({
sendBinaryChunk: vi.fn().mockReturnValue(true)
}))
// Mock connection handlers
vi.mock('./connection', () => ({
waitForSocketDrain: vi.fn().mockResolvedValue(undefined),
getAbortError: vi.fn((signal, fallback) => {
const reason = (signal as AbortSignal & { reason?: unknown }).reason
if (reason instanceof Error) return reason
if (typeof reason === 'string' && reason.length > 0) return new Error(reason)
return new Error(fallback)
})
}))
// Note: validateFile and calculateFileChecksum tests are skipped because
// the test environment has globally mocked node:fs and node:os modules.
// These functions are tested through integration tests instead.
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')
})
})
// Note: streamFileChunks tests require careful mocking of fs.createReadStream
// which is globally mocked in the test environment. These tests verify the
// streaming logic works correctly with mock streams.
describe('streamFileChunks', () => {
let mockSocket: Socket & EventEmitter
let mockProgress: ReturnType<typeof vi.fn>
beforeEach(() => {
vi.clearAllMocks()
mockSocket = Object.assign(new EventEmitter(), {
destroyed: false,
writable: true,
write: vi.fn().mockReturnValue(true),
cork: vi.fn(),
uncork: vi.fn()
}) as unknown as Socket & EventEmitter
mockProgress = vi.fn()
})
afterEach(() => {
vi.resetAllMocks()
})
it('should throw when abort signal is already aborted', async () => {
const transfer = createTransferState('test-id', 'test.zip', 1024, 'checksum')
transfer.abortController.abort(new Error('Already cancelled'))
await expect(
streamFileChunks(mockSocket, '/fake/path.zip', transfer, transfer.abortController.signal, mockProgress)
).rejects.toThrow()
})
// Note: Full integration testing of streamFileChunks with actual file streaming
// requires a real file system, which cannot be easily mocked in ESM.
// The abort signal test above verifies the early abort path.
// Additional streaming tests are covered through integration tests.
})
})

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,67 @@
import type { Socket } from 'node:net'
/**
* Binary protocol constants (v1)
*/
export const BINARY_TYPE_FILE_CHUNK = 0x01
/**
* Send file chunk as binary frame (protocol v1 - 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 {
if (!socket || socket.destroyed || !socket.writable) {
throw new Error('Socket is not writable')
}
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,162 @@
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 = '1'
/** Maximum size for line buffer to prevent memory exhaustion from malicious peers */
const MAX_LINE_BUFFER_SIZE = 1024 * 1024 // 1MB limit for control messages
const logger = loggerService.withContext('LanTransferConnection')
/**
* 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')
// Prevent memory exhaustion from malicious peers sending data without newlines
if (lineBuffer.length > MAX_LINE_BUFFER_SIZE) {
logger.error('Line buffer exceeded maximum size, resetting')
lineBuffer = ''
throw new Error('Control message too large')
}
let newlineIndex = lineBuffer.indexOf('\n')
while (newlineIndex !== -1) {
const line = lineBuffer.slice(0, newlineIndex).trim()
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,267 @@
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 (error) {
const nodeError = error as NodeJS.ErrnoException
if (nodeError.code === 'ENOENT') {
throw new Error(`File not found: ${filePath}`)
} else if (nodeError.code === 'EACCES') {
throw new Error(`Permission denied: ${filePath}`)
} else if (nodeError.code === 'ENOTDIR') {
throw new Error(`Invalid path: ${filePath}`)
} else {
throw new Error(`Cannot access file: ${filePath} (${nodeError.code || 'unknown error'})`)
}
}
if (!stats.isFile()) {
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 (v1 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 (v1 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: v1.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 { GitBashPathInfo, 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 type { MCPServerLogEntry } from '@shared/config/types'
import { IpcChannel } from '@shared/IpcChannel'
import type { Notification } from '@types'
@ -172,7 +180,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> =>
@ -589,12 +601,32 @@ const api = {
writeContent: (options: WritePluginContentOptions): Promise<PluginResult<void>> =>
ipcRenderer.invoke(IpcChannel.ClaudeCodePlugin_WriteContent, options)
},
webSocket: {
start: () => ipcRenderer.invoke(IpcChannel.WebSocket_Start),
stop: () => ipcRenderer.invoke(IpcChannel.WebSocket_Stop),
status: () => ipcRenderer.invoke(IpcChannel.WebSocket_Status),
sendFile: (filePath: string) => ipcRenderer.invoke(IpcChannel.WebSocket_SendFile, filePath),
getAllCandidates: () => ipcRenderer.invoke(IpcChannel.WebSocket_GetAllCandidates)
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)
}
}

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

@ -3232,24 +3232,43 @@
},
"content": "Export some data, including chat logs and settings. Please note that the backup process may take some time. Thank you for your patience.",
"lan": {
"auto_close_tip": "Auto-closing in {{seconds}} seconds...",
"confirm_close_message": "File transfer is in progress. Closing will interrupt the transfer. Are you sure you want to force close?",
"confirm_close_title": "Confirm Close",
"connected": "Connected",
"connection_failed": "Connection failed",
"content": "Please ensure your computer and phone are on the same network for LAN transfer. Open the Cherry Studio App to scan this QR code.",
"content": "Please ensure your computer and phone are on the same network for LAN transfer.",
"device_list_title": "Local network devices",
"discovered_devices": "Discovered devices",
"error": {
"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"
},
"force_close": "Force Close",
"generating_qr": "Generating QR code...",
"noZipSelected": "No compressed file selected",
"scan_qr": "Please scan QR code with your phone",
"selectZip": "Select a compressed file",
"sendZip": "Begin data recovery",
"file_transfer": {
"cancelled": "Transfer cancelled",
"failed": "File transfer failed: {{message}}",
"progress": "Sending... {{progress}}%",
"success": "File sent successfully"
},
"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",
"no_connection_warning": "Please open LAN Transfer on Cherry Studio mobile",
"no_devices": "No LAN peers found yet",
"scan_devices": "Scan devices",
"scanning_hint": "Scanning your local network for Cherry Studio peers...",
"send_file": "Send File",
"status": {
"completed": "Transfer completed",
"connected": "Connected",
@ -3258,9 +3277,11 @@
"error": "Connection error",
"initializing": "Initializing connection...",
"preparing": "Preparing transfer...",
"sending": "Transferring {{progress}}%",
"waiting_qr_scan": "Please scan QR code to connect"
"sending": "Transferring {{progress}}%"
},
"status_badge_idle": "Idle",
"status_badge_scanning": "Scanning",
"stop_scan": "Stop scan",
"title": "LAN transmission",
"transfer_progress": "Transfer progress"
},

View File

@ -3232,24 +3232,43 @@
},
"content": "导出部分数据,包括聊天记录、设置。请注意,备份过程可能需要一些时间,感谢您的耐心等待。",
"lan": {
"auto_close_tip": "{{seconds}} 秒后自动关闭...",
"confirm_close_message": "文件正在传输中,关闭将中断传输。确定要强制关闭吗?",
"confirm_close_title": "确认关闭",
"connected": "连接成功",
"connection_failed": "连接失败",
"content": "请确保电脑和手机处于同一网络以使用局域网传输。请打开 Cherry Studio App 扫描此二维码。",
"content": "请确保电脑和手机处于同一网络以使用局域网传输。",
"device_list_title": "局域网设备列表",
"discovered_devices": "已发现的设备",
"error": {
"file_too_large": "文件过大,最大支持 500MB",
"init_failed": "初始化失败",
"invalid_file_type": "仅支持 ZIP 文件",
"no_file": "未选择文件",
"no_ip": "无法获取 IP 地址",
"not_connected": "请先完成握手连接",
"send_failed": "发送文件失败"
},
"force_close": "强制关闭",
"generating_qr": "正在生成二维码...",
"noZipSelected": "未选择压缩文件",
"scan_qr": "请使用手机扫码连接",
"selectZip": "选择压缩文件",
"sendZip": "开始恢复数据",
"file_transfer": {
"cancelled": "传输已取消",
"failed": "文件发送失败: {{message}}",
"progress": "发送中... {{progress}}%",
"success": "文件发送成功"
},
"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": "元数据",
"no_connection_warning": "请在 Cherry Studio 移动端打开局域网传输",
"no_devices": "尚未发现局域网设备",
"scan_devices": "扫描设备",
"scanning_hint": "正在扫描局域网中的 Cherry Studio 设备...",
"send_file": "发送文件",
"status": {
"completed": "传输完成",
"connected": "连接成功",
@ -3258,9 +3277,11 @@
"error": "连接出错",
"initializing": "正在初始化连接...",
"preparing": "准备传输中...",
"sending": "传输中 {{progress}}%",
"waiting_qr_scan": "请扫描二维码连接"
"sending": "传输中 {{progress}}%"
},
"status_badge_idle": "空闲",
"status_badge_scanning": "扫描中",
"stop_scan": "停止扫描",
"title": "局域网传输",
"transfer_progress": "传输进度"
},

View File

@ -3232,24 +3232,43 @@
},
"content": "匯出部分資料,包括聊天記錄與設定。請注意,備份過程可能需要一些時間,感謝耐心等候。",
"lan": {
"auto_close_tip": "將於 {{seconds}} 秒後自動關閉...",
"confirm_close_message": "檔案傳輸正在進行中。關閉將會中斷傳輸。您確定要強制關閉嗎?",
"confirm_close_title": "確認關閉",
"connected": "已連線",
"connection_failed": "連線失敗",
"content": "請確保電腦和手機處於同一網路以使用區域網路傳輸。請開啟 Cherry Studio App 掃描此 QR 碼。",
"content": "請確保電腦和手機處於同一網路以使用區域網路傳輸。",
"device_list_title": "區域網路裝置",
"discovered_devices": "已發現的裝置",
"error": {
"file_too_large": "檔案過大,僅支援最大 500MB",
"init_failed": "初始化失敗",
"invalid_file_type": "僅支援 ZIP 檔案",
"no_file": "未選擇檔案",
"no_ip": "無法取得 IP 位址",
"not_connected": "請先完成握手",
"send_failed": "無法傳送檔案"
},
"force_close": "強制關閉",
"generating_qr": "正在產生 QR 碼...",
"noZipSelected": "未選取壓縮檔案",
"scan_qr": "請使用手機掃描 QR 碼",
"selectZip": "選擇壓縮檔案",
"sendZip": "開始還原資料",
"file_transfer": {
"cancelled": "傳輸已取消",
"failed": "檔案傳輸失敗:{{message}}",
"progress": "傳送中... {{progress}}%",
"success": "檔案傳送成功"
},
"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": "中繼資料",
"no_connection_warning": "請在 Cherry Studio 行動裝置開啟區域網路傳輸",
"no_devices": "尚未找到區域網路節點",
"scan_devices": "掃描裝置",
"scanning_hint": "正在掃描區域網路中的 Cherry Studio 裝置...",
"send_file": "傳送檔案",
"status": {
"completed": "傳輸完成",
"connected": "已連線",
@ -3258,9 +3277,11 @@
"error": "連線錯誤",
"initializing": "正在初始化連線...",
"preparing": "正在準備傳輸...",
"sending": "傳輸中 {{progress}}%",
"waiting_qr_scan": "請掃描 QR 碼以連線"
"sending": "傳輸中 {{progress}}%"
},
"status_badge_idle": "閒置",
"status_badge_scanning": "掃描中",
"stop_scan": "停止掃描",
"title": "區域網路傳輸",
"transfer_progress": "傳輸進度"
},

View File

@ -3232,24 +3232,43 @@
},
"content": "Exportieren Sie einige Daten, einschließlich Chat-Protokollen und Einstellungen. Bitte beachten Sie, dass der Sicherungsvorgang einige Zeit in Anspruch nehmen kann. Vielen Dank für Ihre Geduld.",
"lan": {
"auto_close_tip": "Automatisches Schließen in {{seconds}} Sekunden...",
"confirm_close_message": "Dateiübertragung läuft. Beim Schließen wird die Übertragung unterbrochen. Möchten Sie wirklich das Schließen erzwingen?",
"confirm_close_title": "Schließen bestätigen",
"connected": "Verbunden",
"connection_failed": "Verbindung fehlgeschlagen",
"content": "Bitte stelle sicher, dass sich dein Computer und dein Telefon im selben Netzwerk befinden, um eine LAN-Übertragung durchzuführen. Öffne die Cherry Studio App, um diesen QR-Code zu scannen.",
"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"
},
"force_close": "Erzwungenes Schließen",
"generating_qr": "QR-Code wird generiert...",
"noZipSelected": "Keine komprimierte Datei ausgewählt",
"scan_qr": "Bitte scannen Sie den QR-Code mit Ihrem Telefon.",
"selectZip": "Wählen Sie eine komprimierte Datei",
"sendZip": "Datenwiederherstellung beginnen",
"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"
},
"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",
"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",
"scanning_hint": "[to be translated]:Scanning your local network for Cherry Studio peers...",
"send_file": "[to be translated]:Send File",
"status": {
"completed": "Übertragung abgeschlossen",
"connected": "Verbunden",
@ -3258,9 +3277,11 @@
"error": "Verbindungsfehler",
"initializing": "Verbindung wird initialisiert...",
"preparing": "Übertragung wird vorbereitet...",
"sending": "Übertrage {{progress}}%",
"waiting_qr_scan": "Bitte QR-Code scannen, um zu verbinden"
"sending": "Übertrage {{progress}}%"
},
"status_badge_idle": "[to be translated]:Idle",
"status_badge_scanning": "[to be translated]:Scanning",
"stop_scan": "[to be translated]:Stop scan",
"title": "LAN-Übertragung",
"transfer_progress": "Übertragungsfortschritt"
},

View File

@ -3232,24 +3232,43 @@
},
"content": "Εξαγωγή μέρους των δεδομένων, συμπεριλαμβανομένων των ιστορικών συνομιλιών και των ρυθμίσεων. Σημειώστε ότι η διαδικασία δημιουργίας αντιγράφων ασφαλείας ενδέχεται να διαρκέσει κάποιο χρονικό διάστημα, ευχαριστούμε για την υπομονή σας.",
"lan": {
"auto_close_tip": "Αυτόματο κλείσιμο σε {{seconds}} δευτερόλεπτα...",
"confirm_close_message": "Η μεταφορά αρχείων είναι σε εξέλιξη. Το κλείσιμο θα διακόψει τη μεταφορά. Είστε σίγουροι ότι θέλετε να κλείσετε βίαια;",
"confirm_close_title": "Επιβεβαίωση Κλεισίματος",
"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": "Αποτυχία αποστολής αρχείου"
},
"force_close": "Κλείσιμο με βία",
"generating_qr": "Δημιουργία κώδικα QR...",
"noZipSelected": "Δεν επιλέχθηκε συμπιεσμένο αρχείο",
"scan_qr": "Παρακαλώ σαρώστε τον κωδικό QR με το τηλέφωνό σας",
"selectZip": "Επιλέξτε συμπιεσμένο αρχείο",
"sendZip": "Έναρξη ανάκτησης δεδομένων",
"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"
},
"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",
"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",
"scanning_hint": "[to be translated]:Scanning your local network for Cherry Studio peers...",
"send_file": "[to be translated]:Send File",
"status": {
"completed": "Η μεταφορά ολοκληρώθηκε",
"connected": "Συνδεδεμένος",
@ -3258,9 +3277,11 @@
"error": "Σφάλμα σύνδεσης",
"initializing": "Αρχικοποίηση σύνδεσης...",
"preparing": "Προετοιμασία μεταφοράς...",
"sending": "Μεταφορά {{progress}}%",
"waiting_qr_scan": "Παρακαλώ σαρώστε τον κωδικό QR για σύνδεση"
"sending": "Μεταφορά {{progress}}%"
},
"status_badge_idle": "[to be translated]:Idle",
"status_badge_scanning": "[to be translated]:Scanning",
"stop_scan": "[to be translated]:Stop scan",
"title": "Μεταφορά τοπικού δικτύου",
"transfer_progress": "Πρόοδος μεταφοράς"
},

View File

@ -3232,24 +3232,43 @@
},
"content": "Exportar parte de los datos, incluidos los registros de chat y la configuración. Tenga en cuenta que el proceso de copia de seguridad puede tardar un tiempo; gracias por su paciencia.",
"lan": {
"auto_close_tip": "Cierre automático en {{seconds}} segundos...",
"confirm_close_message": "La transferencia de archivos está en progreso. Cerrar interrumpirá la transferencia. ¿Estás seguro de que quieres forzar el cierre?",
"confirm_close_title": "Confirmar Cierre",
"connected": "Conectado",
"connection_failed": "Conexión fallida",
"content": "Asegúrate de que el ordenador y el móvil estén en la misma red para usar la transferencia por LAN. Abre la aplicación Cherry Studio y escanea este código QR.",
"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"
},
"force_close": "Cerrar forzosamente",
"generating_qr": "Generando código QR...",
"noZipSelected": "No se ha seleccionado ningún archivo comprimido",
"scan_qr": "Por favor, escanea el código QR con tu teléfono",
"selectZip": "Seleccionar archivo comprimido",
"sendZip": "Comenzar la recuperación de datos",
"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"
},
"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",
"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",
"scanning_hint": "[to be translated]:Scanning your local network for Cherry Studio peers...",
"send_file": "[to be translated]:Send File",
"status": {
"completed": "Transferencia completada",
"connected": "Conectado",
@ -3258,9 +3277,11 @@
"error": "Error de conexión",
"initializing": "Inicializando conexión...",
"preparing": "Preparando transferencia...",
"sending": "Transfiriendo {{progress}}%",
"waiting_qr_scan": "Por favor, escanea el código QR para conectarte"
"sending": "Transfiriendo {{progress}}%"
},
"status_badge_idle": "[to be translated]:Idle",
"status_badge_scanning": "[to be translated]:Scanning",
"stop_scan": "[to be translated]:Stop scan",
"title": "Transferencia de red local",
"transfer_progress": "Progreso de transferencia"
},

View File

@ -3232,24 +3232,43 @@
},
"content": "Exporter une partie des données, incluant les historiques de discussion et les paramètres. Veuillez noter que le processus de sauvegarde peut prendre un certain temps ; merci pour votre patience.",
"lan": {
"auto_close_tip": "Fermeture automatique dans {{seconds}} secondes...",
"confirm_close_message": "Le transfert de fichier est en cours. Fermer interrompra le transfert. Êtes-vous sûr de vouloir forcer la fermeture ?",
"confirm_close_title": "Confirmer la fermeture",
"connected": "Connecté",
"connection_failed": "Échec de la connexion",
"content": "Assurez-vous que l'ordinateur et le téléphone sont connectés au même réseau pour utiliser le transfert en réseau local. Ouvrez l'application Cherry Studio et scannez ce code QR.",
"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"
},
"force_close": "Fermer de force",
"generating_qr": "Génération du code QR...",
"noZipSelected": "Aucun fichier compressé sélectionné",
"scan_qr": "Veuillez scanner le code QR avec votre téléphone",
"selectZip": "Sélectionner le fichier compressé",
"sendZip": "Commencer la restauration des données",
"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"
},
"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",
"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",
"scanning_hint": "[to be translated]:Scanning your local network for Cherry Studio peers...",
"send_file": "[to be translated]:Send File",
"status": {
"completed": "Transfert terminé",
"connected": "Connecté",
@ -3258,9 +3277,11 @@
"error": "Erreur de connexion",
"initializing": "Initialisation de la connexion...",
"preparing": "Préparation du transfert...",
"sending": "Transfert {{progress}} %",
"waiting_qr_scan": "Veuillez scanner le code QR pour vous connecter"
"sending": "Transfert {{progress}} %"
},
"status_badge_idle": "[to be translated]:Idle",
"status_badge_scanning": "[to be translated]:Scanning",
"stop_scan": "[to be translated]:Stop scan",
"title": "Transmission en réseau local",
"transfer_progress": "Progression du transfert"
},

View File

@ -3232,24 +3232,43 @@
},
"content": "一部のデータ、チャット履歴や設定をエクスポートします。バックアップには時間がかかる場合がありますので、しばらくお待ちください。",
"lan": {
"auto_close_tip": "{{seconds}}秒後に自動的に閉じます...",
"confirm_close_message": "ファイル転送が進行中です。閉じると転送が中断されます。強制終了してもよろしいですか?",
"confirm_close_title": "閉じることを確認",
"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": "ファイルの送信に失敗しました"
},
"force_close": "強制終了",
"generating_qr": "QRコードを生成中...",
"noZipSelected": "圧縮ファイルが選択されていません",
"scan_qr": "携帯電話でQRコードをスキャンしてください",
"selectZip": "圧縮ファイルを選択",
"sendZip": "データの復元を開始します",
"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"
},
"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",
"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",
"scanning_hint": "[to be translated]:Scanning your local network for Cherry Studio peers...",
"send_file": "[to be translated]:Send File",
"status": {
"completed": "転送完了",
"connected": "接続済み",
@ -3258,9 +3277,11 @@
"error": "接続エラー",
"initializing": "接続を初期化中...",
"preparing": "転送準備中...",
"sending": "転送中 {{progress}}%",
"waiting_qr_scan": "QRコードをスキャンして接続してください"
"sending": "転送中 {{progress}}%"
},
"status_badge_idle": "[to be translated]:Idle",
"status_badge_scanning": "[to be translated]:Scanning",
"stop_scan": "[to be translated]:Stop scan",
"title": "LAN転送",
"transfer_progress": "転送進行"
},

View File

@ -3232,24 +3232,43 @@
},
"content": "Exportar parte dos dados, incluindo registros de conversas e configurações. Observe que o processo de backup pode demorar um pouco; agradecemos sua paciência.",
"lan": {
"auto_close_tip": "Fechando automaticamente em {{seconds}} segundos...",
"confirm_close_message": "Transferência de arquivo em andamento. Fechar irá interromper a transferência. Tem certeza de que deseja forçar o fechamento?",
"confirm_close_title": "Confirmar Fechamento",
"connected": "Conectado",
"connection_failed": "Falha na conexão",
"content": "Certifique-se de que o computador e o telefone estejam na mesma rede para usar a transferência via LAN. Abra o aplicativo Cherry Studio e escaneie este código QR.",
"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"
},
"force_close": "Forçar Fechamento",
"generating_qr": "Gerando código QR...",
"noZipSelected": "Nenhum arquivo de compressão selecionado",
"scan_qr": "Por favor, escaneie o código QR com o seu telefone",
"selectZip": "Selecionar arquivo compactado",
"sendZip": "Iniciar recuperação de dados",
"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"
},
"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",
"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",
"scanning_hint": "[to be translated]:Scanning your local network for Cherry Studio peers...",
"send_file": "[to be translated]:Send File",
"status": {
"completed": "Transferência concluída",
"connected": "Conectado",
@ -3258,9 +3277,11 @@
"error": "Erro de conexão",
"initializing": "Inicializando conexão...",
"preparing": "Preparando transferência...",
"sending": "Transferindo {{progress}}%",
"waiting_qr_scan": "Por favor, escaneie o código QR para conectar"
"sending": "Transferindo {{progress}}%"
},
"status_badge_idle": "[to be translated]:Idle",
"status_badge_scanning": "[to be translated]:Scanning",
"stop_scan": "[to be translated]:Stop scan",
"title": "transmissão de rede local",
"transfer_progress": "Progresso da transferência"
},

View File

@ -3232,24 +3232,43 @@
},
"content": "Экспорт части данных, включая историю чатов и настройки. Обратите внимание, процесс резервного копирования может занять некоторое время, благодарим за ваше терпение.",
"lan": {
"auto_close_tip": "Автоматическое закрытие через {{seconds}} секунд...",
"confirm_close_message": "Передача файла в процессе. Закрытие прервет передачу. Вы уверены, что хотите принудительно закрыть?",
"confirm_close_title": "Подтвердить закрытие",
"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": "Не удалось отправить файл"
},
"force_close": "Принудительное закрытие",
"generating_qr": "Генерация QR-кода...",
"noZipSelected": "Архив не выбран",
"scan_qr": "Пожалуйста, отсканируйте QR-код с помощью вашего телефона",
"selectZip": "Выберите архив",
"sendZip": "Начать восстановление данных",
"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"
},
"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",
"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",
"scanning_hint": "[to be translated]:Scanning your local network for Cherry Studio peers...",
"send_file": "[to be translated]:Send File",
"status": {
"completed": "Перевод завершён",
"connected": "Подключено",
@ -3258,9 +3277,11 @@
"error": "Ошибка подключения",
"initializing": "Инициализация соединения...",
"preparing": "Подготовка передачи...",
"sending": "Передача {{progress}}%",
"waiting_qr_scan": "Пожалуйста, отсканируйте QR-код для подключения"
"sending": "Передача {{progress}}%"
},
"status_badge_idle": "[to be translated]:Idle",
"status_badge_scanning": "[to be translated]:Scanning",
"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>

198
yarn.lock
View File

@ -4463,6 +4463,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"
@ -7255,13 +7262,6 @@ __metadata:
languageName: node
linkType: hard
"@socket.io/component-emitter@npm:~3.1.0":
version: 3.1.2
resolution: "@socket.io/component-emitter@npm:3.1.2"
checksum: 10c0/c4242bad66f67e6f7b712733d25b43cbb9e19a595c8701c3ad99cbeb5901555f78b095e24852f862fffb43e96f1d8552e62def885ca82ae1bb05da3668fd87d7
languageName: node
linkType: hard
"@standard-schema/spec@npm:^1.0.0":
version: 1.0.0
resolution: "@standard-schema/spec@npm:1.0.0"
@ -8279,7 +8279,7 @@ __metadata:
languageName: node
linkType: hard
"@types/cors@npm:^2.8.12, @types/cors@npm:^2.8.19":
"@types/cors@npm:^2.8.19":
version: 2.8.19
resolution: "@types/cors@npm:2.8.19"
dependencies:
@ -8836,15 +8836,6 @@ __metadata:
languageName: node
linkType: hard
"@types/node@npm:>=10.0.0":
version: 24.3.1
resolution: "@types/node@npm:24.3.1"
dependencies:
undici-types: "npm:~7.10.0"
checksum: 10c0/99b86fc32294fcd61136ca1f771026443a1e370e9f284f75e243b29299dd878e18c193deba1ce29a374932db4e30eb80826e1049b9aad02d36f5c30b94b6f928
languageName: node
linkType: hard
"@types/node@npm:^18.11.18":
version: 18.19.86
resolution: "@types/node@npm:18.19.86"
@ -10202,6 +10193,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"
@ -10290,7 +10282,6 @@ __metadata:
pdf-lib: "npm:^1.17.1"
pdf-parse: "npm:^1.1.1"
proxy-agent: "npm:^6.5.0"
qrcode.react: "npm:^4.2.0"
react: "npm:^19.2.0"
react-dom: "npm:^19.2.0"
react-error-boundary: "npm:^6.0.0"
@ -10322,7 +10313,6 @@ __metadata:
selection-hook: "npm:^1.0.12"
sharp: "npm:^0.34.3"
shiki: "npm:^3.12.0"
socket.io: "npm:^4.8.1"
strict-url-sanitise: "npm:^0.0.1"
string-width: "npm:^7.2.0"
striptags: "npm:^3.2.0"
@ -10385,16 +10375,6 @@ __metadata:
languageName: node
linkType: hard
"accepts@npm:~1.3.4":
version: 1.3.8
resolution: "accepts@npm:1.3.8"
dependencies:
mime-types: "npm:~2.1.34"
negotiator: "npm:0.6.3"
checksum: 10c0/3a35c5f5586cfb9a21163ca47a5f77ac34fa8ceb5d17d2fa2c0d81f41cbd7f8c6fa52c77e2c039acc0f4d09e71abdc51144246900f6bef5e3c4b333f77d89362
languageName: node
linkType: hard
"acorn-jsx@npm:^5.3.2":
version: 5.3.2
resolution: "acorn-jsx@npm:5.3.2"
@ -11033,13 +11013,6 @@ __metadata:
languageName: node
linkType: hard
"base64id@npm:2.0.0, base64id@npm:~2.0.0":
version: 2.0.0
resolution: "base64id@npm:2.0.0"
checksum: 10c0/6919efd237ed44b9988cbfc33eca6f173a10e810ce50292b271a1a421aac7748ef232a64d1e6032b08f19aae48dce6ee8f66c5ae2c9e5066c82b884861d4d453
languageName: node
linkType: hard
"basic-ftp@npm:^5.0.2":
version: 5.0.5
resolution: "basic-ftp@npm:5.0.5"
@ -11155,6 +11128,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"
@ -12266,7 +12249,7 @@ __metadata:
languageName: node
linkType: hard
"cookie@npm:^0.7.1, cookie@npm:~0.7.2":
"cookie@npm:^0.7.1":
version: 0.7.2
resolution: "cookie@npm:0.7.2"
checksum: 10c0/9596e8ccdbf1a3a88ae02cf5ee80c1c50959423e1022e4e60b91dd87c622af1da309253d8abdb258fb5e3eacb4f08e579dc58b4897b8087574eee0fd35dfa5d2
@ -12303,7 +12286,7 @@ __metadata:
languageName: node
linkType: hard
"cors@npm:^2.8.5, cors@npm:~2.8.5":
"cors@npm:^2.8.5":
version: 2.8.5
resolution: "cors@npm:2.8.5"
dependencies:
@ -12973,18 +12956,6 @@ __metadata:
languageName: node
linkType: hard
"debug@npm:~4.3.1, debug@npm:~4.3.2, debug@npm:~4.3.4":
version: 4.3.7
resolution: "debug@npm:4.3.7"
dependencies:
ms: "npm:^2.1.3"
peerDependenciesMeta:
supports-color:
optional: true
checksum: 10c0/1471db19c3b06d485a622d62f65947a19a23fbd0dd73f7fd3eafb697eec5360cde447fb075919987899b1a2096e85d35d4eb5a4de09a57600ac9cf7e6c8e768b
languageName: node
linkType: hard
"decamelize@npm:1.2.0":
version: 1.2.0
resolution: "decamelize@npm:1.2.0"
@ -13373,6 +13344,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"
@ -13941,30 +13921,6 @@ __metadata:
languageName: node
linkType: hard
"engine.io-parser@npm:~5.2.1":
version: 5.2.3
resolution: "engine.io-parser@npm:5.2.3"
checksum: 10c0/ed4900d8dbef470ab3839ccf3bfa79ee518ea8277c7f1f2759e8c22a48f64e687ea5e474291394d0c94f84054749fd93f3ef0acb51fa2f5f234cc9d9d8e7c536
languageName: node
linkType: hard
"engine.io@npm:~6.6.0":
version: 6.6.4
resolution: "engine.io@npm:6.6.4"
dependencies:
"@types/cors": "npm:^2.8.12"
"@types/node": "npm:>=10.0.0"
accepts: "npm:~1.3.4"
base64id: "npm:2.0.0"
cookie: "npm:~0.7.2"
cors: "npm:~2.8.5"
debug: "npm:~4.3.1"
engine.io-parser: "npm:~5.2.1"
ws: "npm:~8.17.1"
checksum: 10c0/845761163f8ea7962c049df653b75dafb6b3693ad6f59809d4474751d7b0392cbf3dc2730b8a902ff93677a91fd28711d34ab29efd348a8a4b49c6b0724021ab
languageName: node
linkType: hard
"enhanced-resolve@npm:^5.18.3":
version: 5.18.3
resolution: "enhanced-resolve@npm:5.18.3"
@ -19198,7 +19154,7 @@ __metadata:
languageName: node
linkType: hard
"mime-types@npm:^2.1.12, mime-types@npm:^2.1.35, mime-types@npm:~2.1.34":
"mime-types@npm:^2.1.12, mime-types@npm:^2.1.35":
version: 2.1.35
resolution: "mime-types@npm:2.1.35"
dependencies:
@ -19602,6 +19558,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"
@ -19685,13 +19653,6 @@ __metadata:
languageName: node
linkType: hard
"negotiator@npm:0.6.3":
version: 0.6.3
resolution: "negotiator@npm:0.6.3"
checksum: 10c0/3ec9fd413e7bf071c937ae60d572bc67155262068ed522cf4b3be5edbe6ddf67d095ec03a3a14ebf8fc8e95f8e1d61be4869db0dbb0de696f6b837358bd43fc2
languageName: node
linkType: hard
"negotiator@npm:^1.0.0":
version: 1.0.0
resolution: "negotiator@npm:1.0.0"
@ -21381,15 +21342,6 @@ __metadata:
languageName: node
linkType: hard
"qrcode.react@npm:^4.2.0":
version: 4.2.0
resolution: "qrcode.react@npm:4.2.0"
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
checksum: 10c0/68c691d130e5fda2f57cee505ed7aea840e7d02033100687b764601f9595e1116e34c13876628a93e1a5c2b85e4efc27d30b2fda72e2050c02f3e1c4e998d248
languageName: node
linkType: hard
"qs@npm:^6.14.0":
version: 6.14.0
resolution: "qs@npm:6.14.0"
@ -23666,41 +23618,6 @@ __metadata:
languageName: node
linkType: hard
"socket.io-adapter@npm:~2.5.2":
version: 2.5.5
resolution: "socket.io-adapter@npm:2.5.5"
dependencies:
debug: "npm:~4.3.4"
ws: "npm:~8.17.1"
checksum: 10c0/04a5a2a9c4399d1b6597c2afc4492ab1e73430cc124ab02b09e948eabf341180b3866e2b61b5084cb899beb68a4db7c328c29bda5efb9207671b5cb0bc6de44e
languageName: node
linkType: hard
"socket.io-parser@npm:~4.2.4":
version: 4.2.4
resolution: "socket.io-parser@npm:4.2.4"
dependencies:
"@socket.io/component-emitter": "npm:~3.1.0"
debug: "npm:~4.3.1"
checksum: 10c0/9383b30358fde4a801ea4ec5e6860915c0389a091321f1c1f41506618b5cf7cd685d0a31c587467a0c4ee99ef98c2b99fb87911f9dfb329716c43b587f29ca48
languageName: node
linkType: hard
"socket.io@npm:^4.8.1":
version: 4.8.1
resolution: "socket.io@npm:4.8.1"
dependencies:
accepts: "npm:~1.3.4"
base64id: "npm:~2.0.0"
cors: "npm:~2.8.5"
debug: "npm:~4.3.2"
engine.io: "npm:~6.6.0"
socket.io-adapter: "npm:~2.5.2"
socket.io-parser: "npm:~4.2.4"
checksum: 10c0/acf931a2bb235be96433b71da3d8addc63eeeaa8acabd33dc8d64e12287390a45f1e9f389a73cf7dc336961cd491679741b7a016048325c596835abbcc017ca9
languageName: node
linkType: hard
"socks-proxy-agent@npm:^8.0.3, socks-proxy-agent@npm:^8.0.5":
version: 8.0.5
resolution: "socks-proxy-agent@npm:8.0.5"
@ -24488,6 +24405,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"
@ -25190,13 +25114,6 @@ __metadata:
languageName: node
linkType: hard
"undici-types@npm:~7.10.0":
version: 7.10.0
resolution: "undici-types@npm:7.10.0"
checksum: 10c0/8b00ce50e235fe3cc601307f148b5e8fb427092ee3b23e8118ec0a5d7f68eca8cee468c8fc9f15cbb2cf2a3797945ebceb1cbd9732306a1d00e0a9b6afa0f635
languageName: node
linkType: hard
"undici@npm:6.21.2":
version: 6.21.2
resolution: "undici@npm:6.21.2"
@ -26224,21 +26141,6 @@ __metadata:
languageName: node
linkType: hard
"ws@npm:~8.17.1":
version: 8.17.1
resolution: "ws@npm:8.17.1"
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: ">=5.0.2"
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
checksum: 10c0/f4a49064afae4500be772abdc2211c8518f39e1c959640457dcee15d4488628620625c783902a52af2dd02f68558da2868fd06e6fd0e67ebcd09e6881b1b5bfe
languageName: node
linkType: hard
"xlsx@https://cdn.sheetjs.com/xlsx-0.20.2/xlsx-0.20.2.tgz":
version: 0.20.2
resolution: "xlsx@https://cdn.sheetjs.com/xlsx-0.20.2/xlsx-0.20.2.tgz"