remove the notes

pull/43/head
David Li 2026-05-14 17:35:27 +08:00
parent 1be706ddb0
commit 36302fb493
2 changed files with 0 additions and 1302 deletions

View File

@ -1,455 +0,0 @@
# wx-cli Architecture Analysis
## Overview
**wx-cli** is a cross-platform Rust CLI tool for extracting and querying local WeChat 4.x data. It decrypts SQLCipher-encrypted databases, caches decrypted copies with mtime-aware invalidation, and provides a daemon-based IPC architecture for fast repeated queries.
**Key characteristics:**
- Single binary, zero runtime dependencies
- Cross-platform: macOS, Linux, Windows
- Millisecond response times via daemon caching
- AI-friendly output (YAML by default, JSON optional)
- All data processed locally, no network calls
---
## High-Level Architecture
```
┌─────────────────────────────────────────────────────────────────────┐
│ wx (CLI client) │
│ src/cli/mod.rs - clap-based command parsing │
│ Commands: init, sessions, history, search, contacts, export, │
│ unread, members, new-messages, stats, favorites, sns-* │
└────────────────────────────┬────────────────────────────────────────┘
│ IPC (Unix socket / Windows named pipe)
┌─────────────────────────────────────────────────────────────────────┐
│ wx-daemon (background process) │
│ src/daemon/mod.rs - tokio async runtime │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐ │
│ │ DbCache │ │ Names │ │ IPC Server │ │
│ │ (mtime-aware)│ │ (contact map)│ │ (JSON line protocol) │ │
│ │ src/daemon/ │ │ src/daemon/ │ │ src/daemon/ │ │
│ │ cache.rs │ │ query.rs │ │ server.rs │ │
│ └──────────────┘ └──────────────┘ └──────────────────────┘ │
│ │
│ On startup: │
│ 1. Load config + keys from ~/.wx-cli/ │
│ 2. Pre-warm: decrypt session.db, sns.db, load contacts │
│ 3. Listen on socket/pipe for requests │
└────────────────────────────┬────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ Crypto Layer │
│ src/crypto/mod.rs + wal.rs │
│ │
│ - SQLCipher 4 page decryption (AES-256-CBC) │
│ - WAL (Write-Ahead Log) application │
│ - Streaming decryption (page-by-page, avoids full-file load) │
└────────────────────────────┬────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ Scanner Layer │
│ src/scanner/{macos,linux,windows}.rs │
│ │
│ Platform-specific memory scanners: │
│ - macOS: Mach VM API (task_for_pid, mach_vm_region, mach_vm_read) │
│ - Linux: /proc/<pid>/mem + /proc/<pid>/maps │
│ - Windows: CreateToolhelp32Snapshot + ReadProcessMemory │
│ │
│ Pattern: x'<64hex_key><32hex_salt>' in WeChat process memory │
└─────────────────────────────────────────────────────────────────────┘
```
---
## Module Breakdown
### 1. Entry Point (`src/main.rs`)
```rust
fn main() {
if std::env::var("WX_DAEMON_MODE").is_ok() {
daemon::run(); // Background daemon mode
} else {
cli::run(); // CLI client mode
}
}
```
Single binary acts as both client and daemon. Daemon spawned via `WX_DAEMON_MODE=1` env var.
---
### 2. CLI Layer (`src/cli/`)
**`mod.rs`** - Command definitions via clap derive macros:
- 17 subcommands (Init, Sessions, History, Search, Contacts, Export, Unread, Members, NewMessages, Stats, Favorites, SnsNotifications, SnsFeed, SnsSearch, Daemon)
- Each command dispatches to dedicated module (e.g., `history::cmd_history`)
- All commands share `--json` flag for output format toggle
**`transport.rs`** - IPC client:
- `ensure_daemon()` - auto-start daemon if not running
- `send()` - JSON line protocol over Unix socket / Windows named pipe
- Timeout handling (15s startup, 120s request)
- Permission preflight check for ~/.wx-cli/ directory
**Command modules** (`sessions.rs`, `history.rs`, etc.):
- Parse CLI args → build IPC `Request`
- Send to daemon → receive `Response`
- Format output (YAML/JSON) via `output.rs`
---
### 3. Daemon Layer (`src/daemon/`)
**`mod.rs`** - Daemon lifecycle:
```rust
async fn async_run() -> Result<()> {
// 1. Create ~/.wx-cli/ + cache/ directories
// 2. Write PID file
// 3. Setup signal handlers (SIGTERM/SIGINT)
// 4. Load config + keys
// 5. Initialize DbCache (mtime-aware decryption cache)
// 6. Pre-warm: load contacts, decrypt session.db + sns.db
// 7. Start IPC server (blocking loop)
}
```
**`cache.rs`** - DbCache (critical performance component):
- `HashMap<String, CacheEntry>` in-memory cache
- `CacheEntry`: `{ db_mtime, wal_mtime, decrypted_path }`
- **mtime-aware invalidation**: re-decrypt only when `.db` or `.db-wal` mtime changes
- Persistent mtime records in `~/.wx-cli/cache/_mtimes.json`
- Cache reuse on daemon restart (avoids re-decryption)
- Uses MD5 hash of rel_key for cache filename
**`server.rs`** - IPC server:
- Unix: `tokio::net::UnixListener` on `~/.wx-cli/daemon.sock`
- Windows: `interprocess` named pipe `\\.\pipe\wx-cli-daemon`
- One connection per request, JSON line protocol
- `dispatch()` routes `Request` → query functions
**`query.rs`** - Query implementations (~1500 lines):
- `Names` struct: contact name cache + MD5→username lookup + verify_flags
- `chat_type_of()`: classify as `private`/`group`/`official_account`/`folded`
- Query functions: `q_sessions`, `q_history`, `q_search`, `q_contacts`, `q_unread`, `q_members`, `q_new_messages`, `q_stats`, `q_favorites`, `q_sns_*`
- Message parsing: zstd decompression, XML extraction (appmsg, sysmsg, revokemsg)
- Uses `spawn_blocking` for SQLite queries (rusqlite is sync)
---
### 4. Crypto Layer (`src/crypto/`)
**`mod.rs`** - SQLCipher 4 decryption:
```rust
// Constants
PAGE_SZ = 4096
SALT_SZ = 16
RESERVE_SZ = 80 // IV(16) + HMAC(64)
// Key operations
fn decrypt_page(enc_key: &[u8; 32], page_data: &[u8], pgno: u32) -> Vec<u8>
fn full_decrypt(db_path: &Path, out_path: &Path, enc_key: &[u8; 32])
```
**Algorithm:**
- AES-256-CBC decryption
- IV located at page end: `PAGE_SZ - RESERVE_SZ` offset
- Page 1 special handling: skip 16-byte SALT, write SQLite magic header
- Other pages: decrypt `[0..PAGE_SZ-RESERVE_SZ]`
- Streaming (page-by-page) to avoid full-file memory load
**`wal.rs`** - WAL application:
- WAL header: 32 bytes (magic, format, page_sz, ckpt_seq, salt1/2, cksum1/2)
- Frame: 24-byte header + PAGE_SZ data
- Frame matching via salt1/2 validation
- Random-write to decrypted DB at `(pgno-1) * PAGE_SZ`
---
### 5. Scanner Layer (`src/scanner/`)
**Common interface** (`mod.rs`):
```rust
pub struct KeyEntry {
db_name: String, // relative path
enc_key: String, // 64-char hex (32 bytes)
salt: String, // 32-char hex (16 bytes)
}
pub fn scan_keys(db_dir: &Path) -> Result<Vec<KeyEntry>> // platform-specific
pub fn read_db_salt(path: &Path) -> Option<String>
pub fn collect_db_salts(db_dir: &Path) -> Vec<(String, String)>
```
**Pattern searched**: `x'<96 hex chars>'` = 64-char key + 32-char salt
**macOS** (`macos.rs`):
- `task_for_pid` → get Mach task port (requires root + ad-hoc signed WeChat)
- `mach_vm_region` → enumerate VM regions
- `mach_vm_read` → read 2MB chunks
- Filter: `VM_PROT_READ | VM_PROT_WRITE` regions only
- Deduplication by (key, salt) pair
**Linux** (`linux.rs`):
- `/proc/<pid>/comm` → find `wechat`/`weixin` process
- `/proc/<pid>/maps` → parse `rw-` regions
- `/proc/<pid>/mem` → seek + read
- Same chunk/dedup strategy
**Windows** (`windows.rs`):
- `CreateToolhelp32Snapshot` → find `Weixin.exe`
- `OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION)`
- `VirtualQueryEx` → enumerate `MEM_COMMIT + PAGE_READWRITE` regions
- `ReadProcessMemory` → chunk read
---
### 6. IPC Protocol (`src/ipc.rs`)
**Request** (tagged enum):
```rust
pub enum Request {
Ping,
Sessions { limit: usize },
History { chat, limit, offset, since, until, msg_type },
Search { keyword, chats, limit, since, until, msg_type },
Contacts { query, limit },
Unread { limit, filter },
Members { chat },
NewMessages { state, limit },
Stats { chat, since, until },
Favorites { limit, fav_type, query },
SnsNotifications { limit, since, until, include_read },
SnsFeed { limit, since, until, user },
SnsSearch { keyword, limit, since, until, user },
}
```
**Response**:
```rust
pub struct Response {
ok: bool,
error: Option<String>,
data: Value, // flattened JSON
}
```
Protocol: newline-delimited JSON, one request per connection.
---
### 7. Config Layer (`src/config.rs`)
**Config struct**:
```rust
pub struct Config {
db_dir: PathBuf, // WeChat db_storage path
keys_file: PathBuf, // all_keys.json
decrypted_dir: PathBuf, // (unused, cache dir used instead)
wechat_process: String, // process name for scanner
}
```
**Paths**:
- `cli_dir()`: `~/.wx-cli/`
- `sock_path()`: `~/.wx-cli/daemon.sock`
- `cache_dir()`: `~/.wx-cli/cache/`
- `mtime_file()`: `~/.wx-cli/cache/_mtimes.json`
**Auto-detection** (`auto_detect_db_dir()`):
- macOS: `~/Library/Containers/com.tencent.xinWeChat/.../xwechat_files/*/db_storage`
- Linux: `~/Documents/xwechat_files/*/db_storage` + legacy path
- Windows: `%APPDATA%/Tencent/xwechat/config/*.ini` → parse data root
---
## Data Flow
### Init Flow (`wx init`)
```
1. Auto-detect db_dir → scan for db_storage directory
2. collect_db_salts(db_dir) → (salt_hex, rel_path) list
3. scan_keys(db_dir) → memory scan → (key_hex, salt_hex) candidates
4. Match: salt_hex == db_salt → KeyEntry { db_name, enc_key, salt }
5. Write ~/.wx-cli/config.json + ~/.wx-cli/all_keys.json
```
### Query Flow (e.g., `wx history "张三"`)
```
1. CLI: parse args → Request::History { chat: "张三", limit: 50 }
2. transport::ensure_daemon() → start if not alive
3. transport::send(Request) → Unix socket/pipe → daemon
4. daemon::dispatch(Request) → q_history()
a. resolve_username("张三") → "wxid_xxx" (fuzzy match against Names)
b. find_msg_tables(db, names, username) → [(db_path, "Msg_<md5>")]
c. spawn_blocking: SQLite query on decrypted db_path
d. decompress_message (zstd) + fmt_content (XML parsing)
5. Response::ok(json!{ chat, messages, ... })
6. CLI: output.rs → YAML/JSON formatting
```
### Decryption Flow (DbCache::get)
```
1. Check in-memory cache: if entry.mtime matches → return cached path
2. mtime mismatch or missing → spawn_blocking decrypt:
a. crypto::full_decrypt(db_path, out_path, enc_key)
b. If .db-wal exists: wal::apply_wal(wal_path, out_path, enc_key)
3. Update cache entry + persist mtimes to _mtimes.json
4. Return decrypted path for SQLite query
```
---
## Database Schema Knowledge
**session/session.db**:
- `SessionTable`: username, unread_count, summary, last_timestamp, last_msg_type, last_msg_sender
**contact/contact.db**:
- `contact`: username, nick_name, remark, verify_flag
- `chat_room`: id, owner (for group info)
- `chatroom_member`: room_id, member_id (joined with contact)
**message/message_N.db**:
- `Msg_<md5(username)>`: local_id, local_type, create_time, real_sender_id, message_content, WCDB_CT_message_content
- `Name2Id`: rowid → user_name (sender lookup)
- WCDB_CT = 4 means zstd compression
**sns/sns.db**:
- `sns_notification`: type (like/comment), from_nickname, content, feed_preview
- `sns_feed_xml`: author, contentDesc, media XML, createTime
**favorite/favorite.db**:
- `fav_db_item`: local_id, type, update_time, content, fromusr
---
## Performance Optimizations
1. **mtime-aware caching**: Only re-decrypt when source file changes
2. **Pre-warming**: Decrypt session.db + sns.db + contacts on daemon start
3. **Arc-wrapped Names**: Contact cache shared via Arc, cloned in O(1)
4. **spawn_blocking**: Sync SQLite ops off async runtime
5. **Streaming decrypt**: Page-by-page, no full file in memory
6. **WAL handling**: Apply uncommitted writes without re-decrypt
7. **MD5 table lookup**: `Msg_<md5>` → username via precomputed hash map
---
## Security Considerations
1. **Root/Admin required**: Memory scan needs elevated privileges
2. **No secrets logged**: Keys written to file, never echoed
3. **Socket permissions**: Unix socket mode 0600
4. **Local-only**: All IPC is localhost, no network exposure
5. **User consent implied**: Only decrypts own WeChat data
---
## Error Handling Patterns
- `anyhow::Result` throughout
- Context messages for chain debugging
- Graceful degradation: missing tables → fallback paths
- Preflight checks (e.g., ~/.wx-cli writable before daemon spawn)
- Signal handlers for clean shutdown (socket/PID file cleanup)
---
## Cross-Platform Notes
| Platform | Scanner API | IPC | Privilege | DB Path |
|----------|-------------|-----|-----------|---------|
| macOS | Mach VM | Unix socket | sudo + codesign | ~/Library/Containers/... |
| Linux | /proc/pid/mem | Unix socket | sudo | ~/Documents/xwechat_files |
| Windows | ToolHelp + ReadProcessMemory | Named pipe | Admin | %APPDATA%/Tencent/xwechat |
---
## Testing Coverage
- `src/crypto/mod.rs`: hex encoding, salt reading, recursive collection
- `src/scanner/macos.rs`: pattern matching (uppercase, dedup, embedded, edge cases)
- Unit tests for helper functions; integration tests would require live WeChat
---
## Extension Points
1. **New commands**: Add to `cli/mod.rs` enum + dispatch + query.rs function
2. **New message types**: Extend `fmt_type()` + `fmt_content()` parsers
3. **New DB sources**: Add to DbCache key list + query functions
4. **Output formats**: Extend `output.rs` formatter
---
## File Structure Summary
```
src/
├── main.rs # Entry point (daemon/CLI switch)
├── config.rs # Config loading + auto-detect
├── ipc.rs # Request/Response protocol types
├── cli/
│ ├── mod.rs # clap command definitions + dispatch
│ ├── transport.rs # IPC client + daemon lifecycle
│ ├── output.rs # YAML/JSON formatting
│ ├── init.rs # wx init implementation
│ ├── sessions.rs # etc. (thin wrappers around IPC)
│ └── daemon_cmd.rs # daemon status/stop/logs
├── daemon/
│ ├── mod.rs # daemon entry + async_run
│ ├── cache.rs # DbCache (mtime-aware decryption cache)
│ ├── server.rs # IPC server (Unix/Windows)
│ └── query.rs # All query implementations
├── crypto/
│ ├── mod.rs # SQLCipher page decryption
│ └── wal.rs # WAL application
└── scanner/
├── mod.rs # common interface + salt collection
├── macos.rs # Mach VM memory scanner
├── linux.rs # /proc scanner
└── windows.rs # Windows API scanner
```
---
## Dependencies
**Core crates:**
- `clap` (derive) - CLI parsing
- `tokio` (full) - async runtime
- `serde`/`serde_json` - serialization
- `rusqlite` (bundled) - SQLite queries
- `aes`/`cbc`/`hmac`/`sha2`/`pbkdf2` - crypto primitives
- `zstd` - message decompression
- `chrono` - timestamp formatting
- `anyhow` - error handling
- `dirs` - home directory
- `md5` - table name hashing
- `regex` - Msg_<md5> pattern matching
**Platform-specific:**
- Unix: `libc` (setsid, signal handling)
- Windows: `windows` crate (process/memory APIs), `interprocess` (named pipes)
---
## Summary
wx-cli is a well-architected Rust project demonstrating:
- Clean separation of CLI/daemon/crypto/scanner layers
- Async-first daemon with sync-offload for SQLite
- Smart caching strategy (mtime-based invalidation)
- Cross-platform memory scanning for SQLCipher key extraction
- AI-friendly output design (YAML default, JSON optional)
- Comprehensive command coverage for WeChat local data

View File

@ -1,847 +0,0 @@
# Communication Layer Analysis & TCP Socket Proposal
## Current Communication Architecture
### Layer Overview
```
┌─────────────────────────────────────────────────────────────┐
│ Protocol Layer │
│ src/ipc.rs │
│ Request / Response types + JSON serialization │
│ (Well abstracted - transport-agnostic) │
└────────────────────────────┬────────────────────────────────┘
┌────────────────────────────┴────────────────────────────────┐
│ Server Layer │
│ src/daemon/server.rs │
│ Platform-specific listeners + connection handlers │
│ (POOR abstraction - duplicated logic per platform) │
└────────────────────────────┬────────────────────────────────┘
┌────────────────────────────┴────────────────────────────────┐
│ Client Layer │
│ src/cli/transport.rs │
│ Platform-specific connection + send functions │
│ (POOR abstraction - duplicated logic per platform) │
└─────────────────────────────────────────────────────────────┘
```
---
## Abstraction Assessment
### Protocol Layer (src/ipc.rs) — **HIGH abstraction**
**Strengths:**
- Pure data types with serde derive
- No transport-specific code
- Clean API: `Request` enum, `Response` struct
- `to_json_line()` helper for serialization
- Transport-agnostic by design
**Example:**
```rust
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "cmd", rename_all = "snake_case")]
pub enum Request {
Ping,
Sessions { limit: usize },
History { chat: String, limit: usize, ... },
// ... all commands
}
pub struct Response {
pub ok: bool,
pub error: Option<String>,
#[serde(flatten)]
pub data: Value,
}
```
**Verdict:** This layer is well-designed and TCP-ready. No changes needed.
---
### Server Layer (src/daemon/server.rs) — **LOW abstraction**
**Current structure:**
```rust
// Top-level entry with #[cfg] branching
pub async fn serve(db, names) -> Result<()> {
#[cfg(unix)]
serve_unix(db, names).await?;
#[cfg(windows)]
serve_windows(db, names).await?;
}
// Unix implementation (40 lines)
#[cfg(unix)]
async fn serve_unix(db, names) -> Result<()> {
let listener = UnixListener::bind(&sock_path)?;
loop {
let (stream, _) = listener.accept().await?;
tokio::spawn(async { handle_connection_unix(stream, db, names) });
}
}
#[cfg(unix)]
async fn handle_connection_unix(stream, db, names) -> Result<()> {
let (reader, mut writer) = stream.into_split();
let mut lines = BufReader::new(reader).lines();
let line = lines.next_line().await?;
let req: Request = serde_json::from_str(&line)?;
let resp = dispatch(req, &db, &names).await;
writer.write_all(resp.to_json_line()?.as_bytes()).await?;
}
// Windows implementation (40 lines) - SAME LOGIC, DIFFERENT TYPES
#[cfg(windows)]
async fn serve_windows(db, names) -> Result<()> {
let listener = ListenerOptions::new().name(name).create_tokio()?;
loop {
let conn = listener.accept().await?;
tokio::spawn(async { handle_connection_windows(conn, db, names) });
}
}
#[cfg(windows)]
async fn handle_connection_windows(conn, db, names) -> Result<()> {
let (reader, mut writer) = tokio::io::split(conn);
let mut lines = BufReader::new(reader).lines();
let line = lines.next_line().await?;
let req: Request = serde_json::from_str(&line)?;
let resp = dispatch(req, &db, &names).await;
writer.write_all(resp.to_json_line()?.as_bytes()).await?;
}
```
**Problems:**
1. **Duplicated connection handling**: `handle_connection_unix` and `handle_connection_windows` have identical logic
2. **No abstraction for stream types**: `UnixStream` vs `interprocess::Stream` handled separately
3. **No abstraction for listener types**: `UnixListener` vs `interprocess::Listener` handled separately
4. **#[cfg] branching at function level**: Makes extension difficult
5. **`dispatch()` is shared but buried**: Good pattern, but underutilized
**Duplication count:** ~30 lines of identical JSON-line protocol handling duplicated per platform
---
### Client Layer (src/cli/transport.rs) — **LOW abstraction**
**Current structure:**
```rust
// is_alive() with #[cfg] branching
pub fn is_alive() -> bool {
#[cfg(unix)]
{
let stream = UnixStream::connect(&sock_path)?;
// ping logic
}
#[cfg(windows)]
{
let stream = Stream::connect(name)?;
// ping logic (different API)
}
}
// send() with #[cfg] branching
pub fn send(req: Request) -> Result<Response> {
ensure_daemon()?;
#[cfg(unix)]
{ send_unix(req) }
#[cfg(windows)]
{ send_windows(req) }
}
#[cfg(unix)]
fn send_unix(req: Request) -> Result<Response> {
let stream = UnixStream::connect(&sock_path)?;
stream.write_all(serde_json::to_string(&req)? + "\n");
let line = BufReader::new(&stream).read_line();
let resp: Response = serde_json::from_str(&line)?;
Ok(resp)
}
#[cfg(windows)]
fn send_windows(req: Request) -> Result<Response> {
let stream = Stream::connect(name)?;
stream.write_all(serde_json::to_string(&req)? + "\n");
let line = BufReader::new(stream).read_line();
let resp: Response = serde_json::from_str(&line)?;
Ok(resp)
}
```
**Problems:**
1. **Duplicated request/response handling**: Same JSON-line protocol, different stream types
2. **No abstraction for stream type**: Each platform uses different types
3. **`is_alive()` logic differs**: Windows version doesn't do full ping
4. **#[cfg] branching scattered**: 3 separate locations
**Duplication count:** ~20 lines of identical protocol handling duplicated per platform
---
## Abstraction Score Summary
| Layer | Abstraction Level | Duplicated Lines | Extension Difficulty |
|----------------|-------------------|------------------|---------------------|
| Protocol | HIGH | 0 | Easy |
| Server | LOW | ~30 | Hard |
| Client | LOW | ~20 | Hard |
**Total duplicated code:** ~50 lines of identical JSON-line protocol handling
**Root cause:** No trait abstraction for `Listener` and `Connection` types
---
## Proposed Architecture for TCP Support
### Strategy: Trait-Based Abstraction
Introduce traits for transport primitives, implement for:
1. Unix socket (existing)
2. Windows named pipe (existing)
3. TCP socket (new)
---
### New Trait Definitions
```rust
// src/transport/traits.rs
use anyhow::Result;
use tokio::io::{AsyncRead, AsyncWrite};
/// Trait for accepting connections (server-side)
pub trait Listener: Send + Sync {
type Connection: AsyncRead + AsyncWrite + Send + Sync + 'static;
async fn accept(&self) -> Result<Self::Connection>;
fn addr_desc(&self) -> String; // for logging
}
/// Trait for connecting to server (client-side)
pub trait Connector: Send + Sync {
type Connection: AsyncRead + AsyncWrite + Send + Sync + 'static;
async fn connect(&self) -> Result<Self::Connection>;
fn is_available(&self) -> bool; // quick check before connect
}
```
---
### New Module Structure
```
src/transport/
├── mod.rs # Public API: send(), handle_connection()
├── traits.rs # Listener + Connector traits
├── unix.rs # UnixListener + UnixConnector
├── windows.rs # PipeListener + PipeConnector
├── tcp.rs # TcpListener + TcpConnector
└── protocol.rs # JSON-line protocol handling (shared)
```
**Key change:** Protocol handling moves to `protocol.rs`, shared by all transports
---
### Protocol Handler (Shared Code)
```rust
// src/transport/protocol.rs
use anyhow::Result;
use tokio::io::{AsyncRead, AsyncWrite, AsyncBufReadExt, AsyncWriteExt, BufReader};
use crate::ipc::{Request, Response};
/// Handle a single connection (server-side)
pub async fn handle_connection<C: AsyncRead + AsyncWrite + Unpin>(
conn: C,
db: Arc<DbCache>,
names: Arc<tokio::sync::RwLock<Arc<Names>>>,
) -> Result<()> {
let (reader, mut writer) = tokio::io::split(conn);
let mut lines = BufReader::new(reader).lines();
let line = match lines.next_line().await? {
Some(l) => l,
None => return Ok(()),
};
let req: Request = match serde_json::from_str(&line) {
Ok(r) => r,
Err(e) => {
let resp = Response::err(format!("JSON parse error: {}", e));
writer.write_all(resp.to_json_line()?.as_bytes()).await?;
return Ok(());
}
};
let resp = dispatch(req, db, names).await;
writer.write_all(resp.to_json_line()?.as_bytes()).await?;
Ok(())
}
/// Send request and receive response (client-side)
pub async fn send_over_connection<C: AsyncRead + AsyncWrite + Unpin>(
conn: C,
req: &Request,
) -> Result<Response> {
let (reader, mut writer) = tokio::io::split(conn);
let req_str = serde_json::to_string(req)? + "\n";
writer.write_all(req_str.as_bytes()).await?;
let mut lines = BufReader::new(reader).lines();
let line = lines.next_line().await?
.ok_or_else(|| anyhow::anyhow!("No response received"))?;
let resp: Response = serde_json::from_str(&line)?;
if !resp.ok {
anyhow::bail!("{}", resp.error.as_deref().unwrap_or("Unknown error"));
}
Ok(resp)
}
```
**This eliminates all 50 lines of duplication.**
---
### Unix Socket Implementation
```rust
// src/transport/unix.rs
use anyhow::Result;
use tokio::net::{UnixListener, UnixStream};
use super::traits::{Listener, Connector};
pub struct UnixSocketListener {
listener: UnixListener,
path: std::path::PathBuf,
}
impl Listener for UnixSocketListener {
type Connection = UnixStream;
async fn accept(&self) -> Result<Self::Connection> {
let (stream, _) = self.listener.accept().await?;
Ok(stream)
}
fn addr_desc(&self) -> String {
self.path.display().to_string()
}
}
pub struct UnixSocketConnector {
path: std::path::PathBuf,
}
impl Connector for UnixSocketConnector {
type Connection = UnixStream;
async fn connect(&self) -> Result<Self::Connection> {
UnixStream::connect(&self.path).await?
}
fn is_available(&self) -> bool {
self.path.exists()
}
}
// Factory functions
pub fn create_listener(path: &std::path::Path) -> Result<UnixSocketListener> {
if path.exists() {
std::fs::remove_file(path)?;
}
let listener = UnixListener::bind(path)?;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
Ok(UnixSocketListener { listener, path: path.to_owned() })
}
pub fn connector(path: &std::path::Path) -> UnixSocketConnector {
UnixSocketConnector { path: path.to_owned() }
}
```
---
### Windows Named Pipe Implementation
```rust
// src/transport/windows.rs
use anyhow::Result;
use interprocess::local_socket::{
tokio::prelude::*,
GenericNamespaced, ListenerOptions,
};
use super::traits::{Listener, Connector};
pub struct PipeListener {
listener: interprocess::local_socket::tokio::Listener,
name: String,
}
impl Listener for PipeListener {
type Connection = interprocess::local_socket::tokio::Stream;
async fn accept(&self) -> Result<Self::Connection> {
self.listener.accept().await?
}
fn addr_desc(&self) -> String {
format!("\\\\.\\pipe\\{}", self.name)
}
}
pub struct PipeConnector {
name: String,
}
impl Connector for PipeConnector {
type Connection = interprocess::local_socket::tokio::Stream;
async fn connect(&self) -> Result<Self::Connection> {
let ns_name = self.name.to_ns_name::<GenericNamespaced>()?;
Stream::connect(ns_name).await?
}
fn is_available(&self) -> bool {
// Windows named pipes don't have filesystem presence
// Try a quick connect to check
self.connect().await.is_ok()
}
}
pub fn create_listener(name: &str) -> Result<PipeListener> {
let ns_name = name.to_ns_name::<GenericNamespaced>()?;
let listener = ListenerOptions::new().name(ns_name).create_tokio()?;
Ok(PipeListener { listener, name: name.to_owned() })
}
pub fn connector(name: &str) -> PipeConnector {
PipeConnector { name: name.to_owned() }
}
```
---
### TCP Socket Implementation (NEW)
```rust
// src/transport/tcp.rs
use anyhow::Result;
use tokio::net::{TcpListener, TcpStream};
use super::traits::{Listener, Connector};
pub struct TcpSocketListener {
listener: TcpListener,
addr: std::net::SocketAddr,
}
impl Listener for TcpSocketListener {
type Connection = TcpStream;
async fn accept(&self) -> Result<Self::Connection> {
let (stream, addr) = self.listener.accept().await?;
eprintln!("[tcp] connection from {}", addr);
Ok(stream)
}
fn addr_desc(&self) -> String {
self.addr.to_string()
}
}
pub struct TcpSocketConnector {
addr: std::net::SocketAddr,
}
impl Connector for TcpSocketConnector {
type Connection = TcpStream;
async fn connect(&self) -> Result<Self::Connection> {
TcpStream::connect(&self.addr).await?
}
fn is_available(&self) -> bool {
// TCP port check - try quick connect
std::net::TcpStream::connect_timeout(&self.addr, std::time::Duration::from_millis(100)).is_ok()
}
}
pub async fn create_listener(bind: &str) -> Result<TcpSocketListener> {
let listener = TcpListener::bind(bind).await?;
let addr = listener.local_addr()?;
Ok(TcpSocketListener { listener, addr })
}
pub fn connector(addr: std::net::SocketAddr) -> TcpSocketConnector {
TcpSocketConnector { addr }
}
```
---
### Server Refactor (src/daemon/server.rs)
```rust
// src/daemon/server.rs
use std::sync::Arc;
use crate::transport::{Listener, handle_connection};
pub async fn serve(
db: Arc<DbCache>,
names: Arc<tokio::sync::RwLock<Arc<Names>>>,
) -> Result<()> {
// Determine transport based on config/env
let listeners: Vec<Box<dyn Listener>> = build_listeners()?;
for listener in listeners {
eprintln!("[server] listening on {}", listener.addr_desc());
let db2 = Arc::clone(&db);
let names2 = Arc::clone(&names);
tokio::spawn(async move {
loop {
match listener.accept().await {
Ok(conn) => {
let db3 = Arc::clone(&db2);
let names3 = Arc::clone(&names2);
tokio::spawn(async move {
if let Err(e) = handle_connection(conn, db3, names3).await {
eprintln!("[server] connection error: {}", e);
}
});
}
Err(e) => eprintln!("[server] accept error: {}", e),
}
}
});
}
// Keep daemon alive
tokio::signal::ctrl_c().await?;
Ok(())
}
fn build_listeners() -> Result<Vec<Box<dyn Listener>>> {
let mut listeners = Vec::new();
// Always add local transport (Unix/Pipe)
#[cfg(unix)]
listeners.push(Box::new(
crate::transport::unix::create_listener(&crate::config::sock_path())?
));
#[cfg(windows)]
listeners.push(Box::new(
crate::transport::windows::create_listener("wx-cli-daemon")?
));
// Optionally add TCP (if configured)
if let Ok(tcp_bind) = std::env::var("WX_TCP_BIND") {
let tcp_listener = crate::transport::tcp::create_listener(&tcp_bind).await?;
eprintln!("[server] TCP enabled on {}", tcp_listener.addr_desc());
listeners.push(Box::new(tcp_listener));
}
Ok(listeners)
}
```
**Key changes:**
1. Single `serve()` function, no #[cfg] branching
2. `build_listeners()` constructs appropriate transport(s)
3. Can listen on multiple transports simultaneously (local + TCP)
4. `handle_connection()` from `transport::protocol` is shared
---
### Client Refactor (src/cli/transport.rs)
```rust
// src/cli/transport.rs (renamed to src/transport/mod.rs)
use anyhow::Result;
use crate::ipc::{Request, Response};
use crate::transport::{Connector, send_over_connection};
pub async fn send(req: Request) -> Result<Response> {
ensure_daemon()?;
// Try connectors in priority order
let connectors = build_connectors();
for connector in connectors {
if connector.is_available() {
let conn = connector.connect().await?;
return send_over_connection(conn, &req).await;
}
}
anyhow::bail!("No available transport to daemon")
}
fn build_connectors() -> Vec<Box<dyn Connector>> {
let mut connectors = Vec::new();
// Local transport first (faster, more secure)
#[cfg(unix)]
connectors.push(Box::new(
crate::transport::unix::connector(&crate::config::sock_path())
));
#[cfg(windows)]
connectors.push(Box::new(
crate::transport::windows::connector("wx-cli-daemon")
));
// TCP fallback (if configured)
if let Ok(tcp_addr) = std::env::var("WX_TCP_ADDR") {
if let Ok(addr) = tcp_addr.parse() {
connectors.push(Box::new(
crate::transport::tcp::connector(addr)
));
}
}
connectors
}
pub fn ensure_daemon() -> Result<()> {
// Try ping on each connector
for connector in build_connectors() {
if connector.is_available() {
// Try quick ping
if let Ok(conn) = connector.connect().await? {
// Use blocking ping for startup check
// ... (existing logic wrapped)
return Ok(());
}
}
}
// No daemon found, start it
start_daemon()?;
// Wait for any connector to become available
let deadline = std::time::Instant::now() + Duration::from_secs(15);
while std::time::Instant::now() < deadline {
for connector in build_connectors() {
if connector.is_available() {
return Ok(());
}
}
std::thread::sleep(Duration::from_millis(300));
}
anyhow::bail!("Daemon startup timeout")
}
```
**Key changes:**
1. Async `send()` using `send_over_connection()`
2. `build_connectors()` returns prioritized list
3. Fallback chain: Unix/Pipe → TCP
4. No #[cfg] branching in main logic
---
## Configuration for TCP
### Environment Variables
```bash
# Server: enable TCP listener
WX_TCP_BIND=127.0.0.1:9876 # bind address (default: none)
WX_TCP_BIND=0.0.0.0:9876 # allow external connections (security risk)
# Client: TCP fallback address
WX_TCP_ADDR=127.0.0.1:9876 # connect address
WX_TCP_ADDR=192.168.1.100:9876 # remote daemon
```
### Config File Extension
```json
// ~/.wx-cli/config.json
{
"db_dir": "...",
"keys_file": "...",
"tcp": {
"bind": "127.0.0.1:9876", // optional
"allow_remote": false // security flag
}
}
```
---
## Security Considerations for TCP
### Risks
1. **No encryption**: JSON-line protocol sent in plaintext
2. **No authentication**: Anyone can query WeChat data
3. **Data exposure**: Chat history, contacts, etc. visible to network
### Recommended Safeguards
```rust
// src/transport/tcp.rs
pub struct TcpSocketListener {
listener: TcpListener,
addr: SocketAddr,
allowed_hosts: Vec<IpNet>, // CIDR whitelist
}
impl Listener for TcpSocketListener {
async fn accept(&self) -> Result<Self::Connection> {
let (stream, addr) = self.listener.accept().await?;
// Check source IP against whitelist
let ip = addr.ip();
if !self.allowed_hosts.iter().any(|net| net.contains(&ip)) {
eprintln!("[tcp] rejected connection from {}", addr);
return Err(anyhow::anyhow!("IP not in whitelist"));
}
Ok(stream)
}
}
// Config
pub struct TcpConfig {
bind: String,
allow_remote: bool,
allowed_hosts: Vec<String>, // ["127.0.0.1/8", "192.168.1.0/24"]
}
```
### Authentication Proposal (Optional)
```rust
// Add to Request enum
pub enum Request {
Auth { token: String }, // new
Ping,
Sessions { ... },
}
// Server checks token before processing
async fn dispatch(req: Request, db: &DbCache, names: &Names, auth: &AuthState) -> Response {
if !auth.is_authenticated() && !req.is_auth_request() {
return Response::err("Not authenticated");
}
// ... normal dispatch
}
```
---
## Implementation Roadmap
### Phase 1: Refactor Existing Code
1. Create `src/transport/` module
2. Define `Listener` and `Connector` traits
3. Move Unix/Pipe implementations to `unix.rs` / `windows.rs`
4. Extract protocol handling to `protocol.rs`
5. Refactor `server.rs` to use trait
6. Refactor `transport.rs` to use trait
**Effort:** ~4 hours
**Benefit:** Eliminate 50 lines duplication, cleaner architecture
### Phase 2: Add TCP Support
1. Create `tcp.rs` with `TcpSocketListener` / `TcpSocketConnector`
2. Update `build_listeners()` / `build_connectors()`
3. Add config parsing for TCP options
4. Add IP whitelist validation
**Effort:** ~2 hours
**Benefit:** TCP connectivity for remote clients
### Phase 3: Security Hardening
1. Add authentication token support
2. TLS wrapper option (tokio-rustls)
3. Connection logging/audit
**Effort:** ~3 hours
**Benefit:** Production-safe remote access
---
## Backwards Compatibility
- Local transport (Unix/Pipe) remains default
- TCP opt-in via config/env (not automatic)
- CLI unchanged (same commands)
- Protocol unchanged (same Request/Response types)
---
## Alternative: Zero-Change TCP Proxy
If refactoring is not desired, a simpler approach:
```bash
# Use socat/proxy to expose Unix socket over TCP
socat TCP-LISTEN:9876,reuseaddr,fork UNIX-CONNECT:/home/user/.wx-cli/daemon.sock
```
**Pros:** No code changes
**Cons:** Requires external tool, no IP filtering, less integrated
---
## Summary
| Aspect | Current State | Proposed State |
|---------------------------|-------------------------|-----------------------------|
| Protocol abstraction | HIGH (good) | HIGH (unchanged) |
| Transport abstraction | LOW (platform-specific) | HIGH (trait-based) |
| Duplicated code | ~50 lines | 0 lines |
| Extension difficulty | Hard | Easy |
| TCP support | None | Full |
| Multi-listener support | None | Yes (local + TCP) |
**Recommended path:** Proceed with Phase 1 refactor, then Phase 2 TCP addition. Phase 3 security can follow based on use case.
---
## Code Impact Summary
| File | Change Type | Lines Changed |
|--------------------------|--------------------|---------------|
| src/transport/mod.rs | New | ~60 |
| src/transport/traits.rs | New | ~20 |
| src/transport/protocol.rs| New (from existing)| ~40 |
| src/transport/unix.rs | New (refactor) | ~40 |
| src/transport/windows.rs | New (refactor) | ~40 |
| src/transport/tcp.rs | New | ~50 |
| src/daemon/server.rs | Refactor | ~30 (from 90) |
| src/cli/transport.rs | Delete (moved) | 0 |
| src/ipc.rs | Unchanged | 0 |
**Net change:** +250 new lines, -90 old lines, -50 duplication = +110 total
**Complexity reduction:** Platform branching centralized, extension point clear