docs/references/lan-transfer-protocol.md
Version: 1.0 Last Updated: 2025-12
This document defines the LAN file transfer protocol between the Cherry Studio desktop client (Electron) and mobile client (Expo).
| Role | Platform | Responsibility |
|---|---|---|
| Client | Electron Desktop | Scan services, initiate connections, send files |
| Server | Expo Mobile | Publish services, accept connections, receive files |
┌─────────────────────────────────────┐
│ Application Layer (File Transfer)│
├─────────────────────────────────────┤
│ Message Layer (Control: JSON \n) │
│ (Data: Binary Frame)│
├─────────────────────────────────────┤
│ Transport Layer (TCP) │
├─────────────────────────────────────┤
│ Discovery Layer (Bonjour/mDNS) │
└─────────────────────────────────────┘
1. Service Discovery → Mobile publishes mDNS service, Desktop scans and discovers
2. TCP Handshake → Establish connection, exchange device info (version=1)
3. File Transfer → Control messages use JSON, file_chunk uses binary frame chunked transfer
4. Keep-alive → ping/pong heartbeat
| Property | Value |
|---|---|
| Service Type | cherrystudio |
| Protocol | tcp |
| Full Service ID | _cherrystudio._tcp |
Mobile must publish the service via mDNS/Bonjour:
{
name: "Cherry Studio Mobile",
type: "cherrystudio",
protocol: "tcp",
port: 53317,
txt: {
version: "1",
platform: "ios" // or "android"
}
}
Desktop scans and resolves service information:
type LanTransferPeer = {
id: string;
name: string;
host?: string;
fqdn?: string;
port?: number;
type?: string;
protocol?: 'tcp' | 'udp';
addresses: string[];
txt?: Record<string, string>;
updatedAt: number;
}
When a service has multiple IP addresses, prefer IPv4:
const preferredAddress = addresses.find((addr) => isIPv4(addr)) || addresses[0]
host:porthandshaketype LanTransferHandshakeMessage = {
type: 'handshake';
deviceName: string;
version: string; // Protocol version, currently "1"
platform?: string; // 'darwin' | 'win32' | 'linux'
appVersion?: string;
}
v1 uses a "control JSON + binary data frame" mixed protocol (streaming mode, no per-chunk ACK):
\n delimitedfile_chunk): Binary frames using Magic + total length for framing, no Base64\n)| Property | Specification |
|---|---|
| Encoding | UTF-8 |
| Serialization | JSON |
| Message Delimiter | \n (0x0A) |
file_chunk Binary Frame FormatTo solve TCP packet splitting/merging and eliminate Base64 overhead, file_chunk uses binary frames with total length:
┌──────────┬──────────┬────────┬───────────────┬──────────────┬────────────┬───────────┐
│ Magic │ TotalLen │ Type │ TransferId Len│ TransferId │ ChunkIdx │ Data │
│ 0x43 0x53│ (4B BE) │ 0x01 │ (2B BE) │ (UTF-8) │ (4B BE) │ (raw) │
└──────────┴──────────┴────────┴───────────────┴──────────────┴────────────┴───────────┘
| Field | Size | Description |
|---|---|---|
| Magic | 2B | Constant 0x43 0x53 ("CS"), distinguishes from JSON messages |
| TotalLen | 4B | Big-endian, total frame length (excluding Magic/TotalLen) |
| Type | 1B | 0x01 for file_chunk |
| TransferId Len | 2B | Big-endian, transferId string length |
| TransferId | nB | UTF-8 transferId (length from previous field) |
| ChunkIdx | 4B | Big-endian, chunk index starting from 0 |
| Data | mB | Raw file binary data (unencoded) |
Total frame length calculation:
TotalLen = 1 + 2 + transferIdLen + 4 + dataLen
0x43 0x53 → parse as binary frame{ → parse as JSON + \n control message| Type | Direction | Encoding | Purpose |
|---|---|---|---|
handshake | Client → Server | JSON+\n | Handshake request (version=1) |
handshake_ack | Server → Client | JSON+\n | Handshake response |
ping | Client → Server | JSON+\n | Heartbeat request |
pong | Server → Client | JSON+\n | Heartbeat response |
file_start | Client → Server | JSON+\n | Start file transfer |
file_start_ack | Server → Client | JSON+\n | File transfer acknowledgment |
file_chunk | Client → Server | Binary | File data chunk (no Base64, streaming, no per-chunk ACK) |
file_end | Client → Server | JSON+\n | File transfer end |
file_complete | Server → Client | JSON+\n | Transfer completion result |
Client (Sender) Server (Receiver)
| |
|──── 1. file_start ────────────────>|
| |
|<─── 2. file_start_ack ─────────────|
| |
|══════ Loop: send data chunks ══════|
| |
|──── 3. file_chunk [0] ────────────>|
|──── 3. file_chunk [1] ────────────>|
| ... repeat until all sent ... |
| |
|──── 5. file_end ──────────────────>|
| |
|<─── 6. file_complete ──────────────|
file_starttype LanTransferFileStartMessage = {
type: 'file_start';
transferId: string; // UUID, unique transfer identifier
fileName: string;
fileSize: number;
mimeType: string;
checksum: string; // SHA-256 hash of entire file (hex)
totalChunks: number;
chunkSize: number;
}
file_start_acktype LanTransferFileStartAckMessage = {
type: 'file_start_ack';
transferId: string;
accepted: boolean;
message?: string; // Rejection reason
}
file_chunk — Binary FrameSee section 4.2 for frame format. Data is raw file binary data. Integrity relies on file_start.checksum (full file SHA-256).
file_endtype LanTransferFileEndMessage = {
type: 'file_end';
transferId: string;
}
file_completetype LanTransferFileCompleteMessage = {
type: 'file_complete';
transferId: string;
success: boolean;
filePath?: string; // Save path (on success)
error?: string; // Error message (on failure)
}
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')
}
const CHUNK_SIZE = 512 * 1024 // 512KB
const totalChunks = Math.ceil(fileSize / CHUNK_SIZE)
ping (Client → Server): { type: 'ping', payload?: string }pong (Server → Client): { type: 'pong', received: boolean, payload?: string }ping immediately after successful handshake to verify connection| Operation | Timeout | Description |
|---|---|---|
| TCP Connection | 10s | Connection establishment timeout |
| Handshake | 10s | Waiting for handshake_ack |
| Transfer Complete | 60s | Waiting for file_complete |
| Scenario | Client Handling | Server Handling |
|---|---|---|
| TCP connection failure | Notify UI, allow retry | - |
| Handshake timeout | Disconnect, notify UI | Close socket |
| Handshake rejected | Show rejection reason | - |
| Chunk processing failure | Abort transfer, cleanup | Clean up temp files |
| Unexpected disconnect | Cleanup state, notify UI | Clean up temp files |
| Insufficient storage | - | Send accepted: false |
export const LAN_TRANSFER_PROTOCOL_VERSION = '1'
export const LAN_TRANSFER_SERVICE_TYPE = 'cherrystudio'
export const LAN_TRANSFER_SERVICE_FULL_NAME = '_cherrystudio._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 minutes
export const LAN_TRANSFER_HANDSHAKE_TIMEOUT_MS = 10_000
export const LAN_TRANSFER_CHUNK_TIMEOUT_MS = 30_000
export const LAN_TRANSFER_COMPLETE_TIMEOUT_MS = 60_000
export const LAN_TRANSFER_ALLOWED_EXTENSIONS = ['.zip']
export const LAN_TRANSFER_ALLOWED_MIME_TYPES = ['application/zip', 'application/x-zip-compressed']
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Renderer│ │ Main │ │ Mobile │
│ (UI) │ │ Process │ │ Server │
└────┬────┘ └────┬────┘ └────┬────┘
│ │ │
│ ═══════ Service Discovery ═════════ │
│ startScan() │ │
│────────────────────────────────────>│ mDNS browse │
│ │ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─>│
│ │<─ ─ ─ service discovered ─ ─ ─ ─ ─ ─│
│<────── onServicesUpdated ───────────│ │
│ │ │
│ ═══════ Handshake ════════════════ │
│ connect(peer) │ │
│────────────────────────────────────>│──────── TCP Connect ───────────────>│
│ │──────── handshake ─────────────────>│
│ │<─────── handshake_ack ──────────────│
│ │──────── ping ──────────────────────>│
│ │<─────── pong ───────────────────────│
│<────── connect result ──────────────│ │
│ │ │
│ ═══════ File Transfer ════════════ │
│ sendFile(path) │ │
│────────────────────────────────────>│──────── file_start ────────────────>│
│ │<─────── file_start_ack ─────────────│
│ │──────── file_chunk[0] (binary) ────>│
│<────── progress event ──────────────│ │
│ │──────── file_chunk[1] (binary) ────>│
│<────── progress event ──────────────│ ... repeat ... │
│ │──────── file_end ──────────────────>│
│ │<─────── file_complete ──────────────│
│<────── complete event ──────────────│ │
_cherrystudio._tcp service on TCP port 53317\n JSON; data messages via binary frames (Magic+TotalLen framing)handshake, send handshake_ack, respond to pingfile_start, receive file_chunk binary frames (write to file + incremental hash), process file_end, send file_completeReact Native / Expo:
react-native-zeroconf or @homielab/react-native-bonjourreact-native-tcp-socketexpo-crypto or react-native-quick-cryptoComplete type definitions are located in src/shared/types/lanTransfer.ts. See the source code for the full interface definitions.
| Version | Date | Changes |
|---|---|---|
| 1.0 | 2025-12 | Initial release with binary frame format and streaming transfer |