UniClipboard CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Project Overview
uniclipboard-desktop is a cross-platform clipboard synchronization tool built with Tauri 2, React, and Rust. It enables real-time clipboard sharing between devices on LAN (WebSocket) and remotely (WebDAV), with XChaCha20-Poly1305 encryption for security.
Architecture Documentation
For detailed architecture design, interaction flows, and system overview, refer to the project's DeepWiki documentation:
- URL: https://deepwiki.com/UniClipboard/UniClipboard
- Access: Use
mcp-deepwikiMCP server to query the documentation programmatically
This resource provides comprehensive diagrams, flow explanations, and design decisions that complement the code structure.
Development Commands
Core Development
# Install dependencies (uses Bun)
bun install
# Start development server (frontend on :1420, backend hot-reload)
bun tauri dev
# Build for production
bun tauri build
# Frontend-only development
bun run dev # Start Vite dev server
bun run build # Build frontend with TypeScript check
bun run preview # Preview production build
Cross-Platform Building
Building is handled via GitHub Actions. Trigger manually from GitHub Actions tab with:
- platform: macos-aarch64, macos-x86_64, ubuntu-22.04, windows-latest, or all
- version: Version number (e.g., 1.0.0)
Cargo Command Location
CRITICAL: All Rust-related commands (cargo build, cargo test, cargo check, etc.) MUST be executed from src-tauri/.
# ✅ CORRECT - Always run from src-tauri/
cd src-tauri && cargo build
cd src-tauri && cargo test
cd src-tauri && cargo check
# ❌ FORBIDDEN - Never run from project root
cargo build
cargo test
Never run any Cargo command from the project root. If Cargo.toml is not present in the current directory, stop immediately and do not retry.
Test Coverage
Generate local coverage report using cargo-llvm-cov:
bun run test:coverage
open src-tauri/target/llvm-cov/html/index.html
Coverage is automatically uploaded to Codecov on each push/PR for tracking incremental changes.
Logging
Overview
The application uses tracing crate as the primary logging framework with structured logging and span-based context tracking.
Supported Features:
- ✅ Spans - Structured context spans with parent-child relationships (e.g.,
tracing::info_span!) - ✅ Structured fields - Field-based logging with typed values
- ✅ Event logging -
tracing::info!,tracing::error!, etc.
See docs/architecture/logging-architecture.md for detailed architecture, span naming conventions, and configuration.
Configuration
Logging is initialized in src-tauri/src/main.rs using init_tracing_subscriber() from src-tauri/crates/uc-tauri/src/bootstrap/tracing.rs.
Environment Behavior
- Development: Debug level,
tracing::*outputs to terminal, legacylog::*outputs to Webview console - Production: Info level,
tracing::*outputs to stdout, legacylog::*outputs touniclipboard.log+ stdout
Log File Locations
- macOS:
~/Library/Logs/com.uniclipboard/uniclipboard.log - Linux:
~/.local/share/com.uniclipboard/logs/uniclipboard.log - Windows:
%LOCALAPPDATA%\com.uniclipboard\logs/uniclipboard.log
Using Logs in Code
use tracing::{info, error, warn, debug, trace, info_span, Instrument};
pub fn my_function() {
info!("Something happened");
error!("Something went wrong: {}", error);
debug!("Detailed debugging info");
}
// For async operations with spans
pub async fn my_async_function() {
let span = info_span!("usecase.example.execute", param = %value);
async move {
info!("Processing...");
}.instrument(span).await
}
Span Best Practices
CRITICAL: Understand the difference between Spans and Events:
- Span = An operation's time range (has a beginning and end)
- Event = Something that happens at a single moment in time
Correct Pattern: Use spans to represent each operation step, not just individual debug events:
// ❌ WRONG - Too many debug events, no span context
debug!("Deleting selection");
self.selection_repo.delete_selection(entry_id).await?;
debug!("Selection deleted");
// ✅ CORRECT - Use span to represent the operation
self.selection_repo
.delete_selection(entry_id)
.instrument(info_span!("delete_selection", entry_id = %entry_id))
.await?;
// ✅ CORRECT - Span for async blocks with multiple steps
let entry = async {
self.entry_repo
.get_entry(entry_id)
.await?
.ok_or_else(|| anyhow::anyhow!("Entry not found: {}", entry_id))
}
.instrument(info_span!("fetch_entry", entry_id = %entry_id))
.await?;
Span Hierarchy Example:
usecase.delete_clipboard_entry.execute ← #[instrument] auto-created
├── fetch_entry ← Manual span
├── delete_selection ← Manual span
├── delete_entry ← Manual span
└── delete_event ← Manual span
Key Benefits:
- Spans automatically record operation start/end time
- Tokio Console and log aggregators show complete call hierarchy
- Reduces redundant log code
- Each operation's duration is automatically tracked
- Better for debugging async systems where multiple operations interleave
When to Use Events vs Spans:
- Use events (
info!,error!, etc.) for single-moment occurrences (errors, state changes) - Use spans (
info_span!+.instrument()) for operations with duration - Use
#[tracing::instrument]on functions to auto-create spans with parameters as fields
Sources:
Viewing Logs
Development:
- Terminal: Logs appear in the terminal where
bun tauri devis running - Browser: Open DevTools (F12) → Console tab
Production:
- Check the log file at the platform-specific location above
- Run
tail -f ~/Library/Logs/com.uniclipboard/uniclipboard.log(macOS) for live monitoring
Log Filtering
The logging system filters out:
libp2p_mdnserrors below WARN level (harmless proxy software errors)- Tauri internal event logs to avoid infinite loops
ipc::requestlogs in production builds
See src-tauri/crates/uc-tauri/src/bootstrap/tracing.rs for tracing configuration and logging.rs for legacy log configuration.
Architecture
Backend (Rust with Tauri 2)
NOTE: The backend is currently undergoing a major refactoring from Clean Architecture to Hexagonal Architecture. Both old and new code coexist during the transition.
New Architecture (Target)
The new architecture follows Hexagonal Architecture (Ports and Adapters) with crate-based modularization:
src-tauri/crates/
├── uc-core/ # Core domain layer (90% complete)
│ ├── clipboard/ # Clipboard aggregate root
│ ├── device/ # Device aggregate root
│ ├── network/ # Network domain models
│ ├── security/ # Security domain models
│ ├── settings/ # Settings domain models
│ └── ports/ # Port definitions (traits)
│ ├── clipboard/
│ ├── security/
│ └── blob/
├── uc-infra/ # Infrastructure implementations (60% complete)
│ ├── db/ # Database layer
│ │ ├── mapper/ # Entity mappers
│ │ ├── models/ # Database models
│ │ └── repositories/ # Repository implementations
│ ├── security/ # Encryption implementations
│ └── settings/ # Settings storage
├── uc-platform/ # Platform adapter layer (70% complete)
│ ├── adapters/ # Platform-specific adapters
│ ├── app_runtime/ # Application runtime
│ ├── ipc/ # IPC event/command system
│ └── ports/ # Platform port definitions
└── uc-app/ # Application layer (30% complete)
├── event/ # Event handling
├── state/ # Application state
└── use_cases/ # Use case implementations
Dependency flow: uc-app → uc-core ← uc-infra / uc-platform
Key architectural changes:
- Port/Adapter pattern: All external dependencies accessed through trait ports
- Message-driven runtime: Async event-based system replacing global state
- Crate boundaries: Enforced separation through Rust module system
Legacy Architecture (Being Replaced)
The old architecture follows traditional Clean Architecture:
src-tauri/src/
├── domain/ # Core business models
├── interface/ # Trait definitions
├── infrastructure/ # External implementations
│ ├── clipboard/ # Platform-specific clipboard
│ ├── p2p/ # P2P network (libp2p)
│ ├── security/ # XChaCha20-Poly1305 encryption
│ ├── storage/ # Diesel ORM + SQLite
│ └── sync/ # WebSocket/WebDAV sync
├── application/ # Business services
├── config/ # TOML-based settings
├── api/ # Tauri command handlers
└── main.rs # Application entry point
Status: Legacy code is still in use. Migration is in progress (~40% overall complete).
Concurrency patterns (legacy):
- Tokio async runtime for I/O
Arc<Mutex<T>>for shared state- Global
SETTINGRwLock for configuration
Frontend (React 18 + TypeScript + Vite)
src/
├── pages/ # Route pages (Dashboard, Devices, Settings)
├── components/ # Reusable UI components (Shadcn/ui based)
├── layouts/ # Layout wrappers
├── store/ # Redux Toolkit slices (state management)
├── api/ # Tauri command invocations
├── contexts/ # React Context (SettingsProvider)
├── hooks/ # Custom React hooks
└── lib/ # Utilities (cn, shadcn UI helpers)
State management: Redux Toolkit with RTK Query Routing: React Router v7 UI: Tailwind CSS + Shadcn/ui components (Radix UI primitives)
Key Technical Details
Path Aliases
TypeScript path aliases configured: @/* maps to src/* (tsconfig.json:24-27)
Database Migrations
Diesel migrations in src-tauri/src/infrastructure/storage/db/migrations.rs. Run with diesel migration run (requires Diesel CLI setup).
Security Implementation
- Encryption: XChaCha20-Poly1305 AEAD for clipboard content (
src-tauri/crates/uc-infra/src/security/encryption.rs)- Chosen for its large nonce (192-bit) reducing nonce reuse risks
- Provides authenticated encryption with associated data (AEAD)
- Suitable for cross-platform applications with software-only implementation
- Password hashing: Argon2 via Tauri Stronghold plugin
- Key storage: Key slot file system with KEK-wrapped master keys (
src-tauri/crates/uc-infra/src/fs/key_slot_store.rs) - Key derivation: Argon2id for passphrase-to-key derivation
Note: The aes-gcm dependency in Cargo.toml is currently unused and can be removed in a future cleanup.
Event System
- Frontend listens to clipboard changes via
listen_clipboard_new_contentTauri command - Backend publishes events through custom event bus
- WebSocket events for cross-device sync
Platform-Specific Code
- macOS: Transparent title bar, cocoa background color (main.rs:169-191)
- Windows/Unix: Standard window decorations
- Clipboard: Platform implementations in infrastructure/clipboard/
Configuration
Settings stored in TOML, managed by global SETTING RwLock (config/setting.rs). Includes:
- General (silent_start, etc.)
- Network (webserver_port)
- Sync (websocket/webdav settings)
- Security (encryption password)
- Storage limits
Clipboard Capture Integration
Automatic Capture Flow
The application automatically captures clipboard content when it changes:
- ClipboardWatcher (Platform Layer) monitors system clipboard
- Sends
PlatformEvent::ClipboardChanged { snapshot }when change detected - PlatformRuntime receives event and calls
ClipboardChangeHandlercallback - AppRuntime implements the callback, invokes
CaptureClipboardUseCase - UseCase persists event, representations, and creates
ClipboardEntry
Important: Callback Architecture
The integration uses a callback pattern maintaining proper layer separation:
- Platform Layer → depends on
ClipboardChangeHandlertrait (in uc-core/ports) - App Layer → implements
ClipboardChangeHandlertrait - Platform pushes changes upward via trait call
- No dependency from Platform to App (follows DIP)
When Modifying
- Platform Layer: Never call App layer directly, use callback trait
- App Layer: Implement callback to handle events, can call multiple use cases
- UseCase:
execute_with_snapshot()for automatic capture,execute()for manual
Tauri Commands
All frontend-backend communication through Tauri commands defined in commands/ (new architecture) and api/ (legacy).
Current Commands (Hexagonal Architecture)
Clipboard Commands:
get_clipboard_entries- List clipboard history entries (usesListClipboardEntriesuse case)delete_clipboard_entry- Delete a clipboard entry (usesDeleteClipboardEntryuse case)capture_clipboard- Manually capture clipboard content (usesCaptureClipboarduse case)
Encryption Commands:
initialize_encryption- Initialize encryption with passphrase (usesInitializeEncryptionuse case)is_encryption_initialized- Check encryption initialization status (usesIsEncryptionInitializeduse case)
Settings Commands (⚠️ Legacy - needs migration):
get_settings- Get application settings (direct Port access)update_settings- Update application settings (direct Port access)
Architecture Pattern
Commands MUST follow the UseCases accessor pattern:
#[tauri::command]
pub async fn example_command(
runtime: State<'_, AppRuntime>,
) -> Result<(), String> {
let uc = runtime.usecases().example_use_case();
uc.execute().await.map_err(|e| e.to_string())
}
Status: See docs/architecture/commands-status.md for detailed migration status.
Commands Layer Status
Current Migration Status: 5/7 commands using UseCases accessor (71%)
When adding new commands:
- Define command function in
src-tauri/crates/uc-tauri/src/commands/ - Create/refer to use case in
uc-app/src/usecases/ - Add accessor method to
UseCasesinsrc-tauri/crates/uc-tauri/src/bootstrap/runtime.rs - Register in
invoke_handler![]insrc-tauri/src/main.rs - Use
runtime.usecases().xxx()- NEVERruntime.deps.xxx
See docs/architecture/commands-status.md for detailed status.
Development Notes
- Package manager: Bun (not npm/yarn) - faster install/dev times
- Dev server port: 1420 (configured in tauri.conf.json:8)
- Release optimization: Size-optimized Rust profile (LTO, panic=abort, strip symbols) (Cargo.toml:87-92)
- Single instance: Enforced via
tauri-plugin-single-instance - Autostart: Managed via
tauri-plugin-autostart(MacOS LaunchAgent on macOS)
Development Style
Problem-Solving Philosophy
CRITICAL: Don't treat symptoms in isolation. Always step back and analyze problems from a higher-level perspective before implementing fixes.
Symptoms vs. Root Causes:
❌ ANTI-PATTERN - Symptom-focused
"Component renders wrong" → Add useEffect hack → "State desync" → Add more hacks → Spaghetti code
✅ CORRECT - Root cause analysis
"Component renders wrong" → Trace data flow → Identify architectural gap → Design proper solution → Fix at the right layer
High-Level Thinking Checklist:
Before making changes, ask:
-
Where does this problem originate?
- UI layer issue, or state management problem?
- API contract mismatch, or business logic gap?
- Infrastructure limitation, or architectural flaw?
-
What's the systemic fix?
- Can this be solved by improving the abstraction?
- Would a design pattern eliminate this class of bugs?
- Is there a missing piece in the architecture?
-
What are the trade-offs?
- Short-term hack vs. long-term maintainability
- Local fix vs. systemic improvement
- Quick workaround vs. proper solution
Examples:
// ❌ WRONG - Treating symptoms everywhere
async fn sync_clipboard() {
match send_to_device().await {
Err(_) => sleep(Duration::from_secs(1)).await, // Band-aid
Ok(_) => {}
}
}
// ✅ CORRECT - Fix the retry logic at the infrastructure layer
// infrastructure/sync/retry_policy.rs
pub struct RetryPolicy {
max_attempts: u32,
backoff_strategy: BackoffStrategy,
}
async fn sync_clipboard_with_retry(policy: &RetryPolicy) -> Result<()> {
policy.execute(|| send_to_device()).await
}
// ❌ WRONG - Local state patch
function DeviceList() {
const [devices, setDevices] = useState([])
useEffect(() => {
fetchDevices().then(setDevices)
setInterval(() => fetchDevices().then(setDevices), 5000) // Manual polling
}, [])
}
// ✅ CORRECT - Leverage existing state management (Redux RTK Query)
function DeviceList() {
const { data: devices } = useGetDevicesQuery() // Built-in caching, refetch, error handling
}
Rationale: High-level problem-solving prevents technical debt, reduces code complexity, and creates more maintainable solutions. Always identify the root cause and fix it at the appropriate abstraction layer.
Rust Error Handling
CRITICAL: Never use unwrap() or expect() in production code. Always handle errors explicitly:
// ❌ FORBIDDEN
let value = some_option.unwrap();
let result = some_result.expect("failed");
// ✅ CORRECT - Use pattern matching
match some_option {
Some(value) => { /* handle value */ },
None => { /* handle error case */ },
}
// ✅ CORRECT - Use ? operator with proper error propagation
pub fn do_something() -> Result<(), MyError> {
let value = some_option.ok_or(MyError::NotFound)?;
// ...
}
// ✅ CORRECT - Use unwrap_or/unwrap_or_default for non-critical defaults
let value = some_option.unwrap_or_default();
let config = config_option.unwrap_or_else(|| Config::default());
// ✅ ACCEPTABLE in tests only
#[cfg(test)]
mod tests {
#[test]
fn test_something() {
let value = some_option.unwrap(); // OK in tests
}
}
Rationale: Explicit error handling prevents panics in production, provides better error messages, and makes failure modes visible to callers.
Avoid Silent Failures in Event-Driven Code
CRITICAL: When handling events or commands in async/event-driven systems, never silently ignore errors. Always log errors and emit failure events when appropriate.
Anti-Pattern: Silent failures with if let Ok(...):
// ❌ WRONG - Silent failure, caller never knows the operation failed
NetworkCommand::SendPairingRequest { peer_id, message } => {
if let Ok(peer) = peer_id.parse::<PeerId>() {
self.swarm.send_request(&peer, request);
debug!("Sent pairing request to {}", peer_id);
}
// If parsing fails, execution silently continues - user has no feedback!
}
Correct Pattern: Explicit error handling with logging and event emission:
// ✅ CORRECT - Log error and emit event for frontend to handle
NetworkCommand::SendPairingRequest { peer_id, message } => {
match peer_id.parse::<PeerId>() {
Ok(peer) => {
self.swarm.send_request(&peer, request);
debug!("Sent pairing request to {}", peer_id);
}
Err(e) => {
warn!("Invalid peer_id '{}': {}", peer_id, e);
let _ = self
.event_tx
.send(NetworkEvent::Error(format!(
"Failed to send pairing request: invalid peer_id '{}': {}",
peer_id, e
)))
.await;
}
}
}
Key Rules:
- Use
matchinstead ofif let- When theErrcase represents a failure that users should know about - Always log errors - Use
warn!()orerror!()to ensure failures are visible in logs - Emit error events - Send
NetworkEvent::Erroror equivalent so the UI can display user-friendly error messages - Handle missing resources - When an expected resource (like a pending channel) is missing, log a warning
When to use if let vs match:
// ✅ OK - Using if let when the None/Err case is truly benign
if let Some(value) = optional_cache.get(&key) {
// Use cached value
}
// ✅ OK - Using if let when fallback behavior is acceptable
if let Ok(config) = read_config() {
apply_config(config);
} else {
use_default_config(); // Explicit fallback
}
// ❌ WRONG - Using if let when failure should be reported
if let Ok(peer_id) = str.parse::<PeerId>() {
send_request(peer_id);
}
// Error is swallowed!
Tauri State Management
CRITICAL: All state accessed via tauri::State<'_, T> in commands MUST be registered with .manage() before the app starts.
Common Error: state not managed for field 'X' on command 'Y'. You must call .manage() before using this command
Root Cause: When a Tauri command uses state: tauri::State<'_, MyType> to access shared state, MyType must be registered in the Builder setup using .manage().
Correct Pattern:
// ❌ WRONG - AppRuntimeHandle created internally, never managed
// main.rs
fn run_app(setting: Setting) {
Builder::default()
.setup(|app| {
// AppRuntime creates its own channels internally
let runtime = AppRuntime::new(...).await?;
// No .manage() call - commands will fail!
Ok(())
})
}
// api/clipboard_items.rs
#[tauri::command]
pub async fn get_clipboard_items(
state: tauri::State<'_, AppRuntimeHandle>, // ERROR: not managed!
) -> Result<Vec<Item>, String> {
// ...
}
// ✅ CORRECT - Create channels before setup, manage the handle
// main.rs
fn run_app(setting: Setting) {
// Create channels FIRST
let (clipboard_cmd_tx, clipboard_cmd_rx) = mpsc::channel(100);
let (p2p_cmd_tx, p2p_cmd_rx) = mpsc::channel(100);
// Create handle with senders
let handle = AppRuntimeHandle::new(clipboard_cmd_tx, p2p_cmd_tx, Arc::new(setting));
Builder::default()
.manage(handle) // Register BEFORE setup
.setup(move |app| {
// Pass receivers to runtime
AppRuntime::new_with_channels(..., clipboard_cmd_rx, p2p_cmd_rx).await
})
}
Key Rules:
- Create channels before Builder - Senders and receivers must be created outside
.setup() - Register with .manage() - Any type accessed via
tauri::Statemust be managed - Clone senders, move receivers - Senders can be cloned for the handle, receivers move to the runtime
- Use Arc for shared immutable data - Config and other read-only data should use
Arc<T>
Rationale: Tauri's state system requires explicit registration to ensure thread safety and proper lifetime management. Commands can only access state that was registered before the app started.
Frontend Styling (Tailwind CSS)
CRITICAL: Avoid fixed pixel values (w-[XXpx], h-[XXpx]) for cross-platform compatibility. Use Tailwind's built-in utilities or relative units (rem) instead:
// ❌ FORBIDDEN - Fixed pixels don't scale across platforms/DPI
<div className="w-[200px] h-[60px]" />
<div className="min-w-[80px]" />
<div className="h-[1px]" />
// ✅ CORRECT - Use Tailwind utilities (rem-based)
<div className="w-52 h-15" /> // w-52 = 13rem, h-15 = 3.75rem
<div className="min-w-20" /> // min-w-20 = 5rem
<div className="h-px" /> // 1px height (special case)
// ✅ CORRECT - Use rem values directly when needed
<div className="w-[3.75rem]" /> // 60px = 3.75rem
<div className="h-[0.0625rem]" /> // 1px = 0.0625rem
// ✅ ACCEPTABLE - For truly fixed sizes (borders, shadows, etc.)
<div className="border shadow-lg" />
Rationale: Rem-based units scale with the root font size, providing better cross-platform consistency across different screen densities, DPI settings, and user accessibility preferences. Tailwind's default configuration uses 1rem = 16px.
Common Tailwind Width Reference:
w-16= 4rem (64px)w-20= 5rem (80px)w-52= 13rem (208px)h-px= 1px (special utility)
Testing
No test framework currently configured. When adding tests:
- Rust tests go in
src-tauri/tests/or inline#[cfg(test)]modules - Frontend tests use Vitest (add to devDependencies)
- Integration tests can use Cargo features:
integration_tests,network_tests,hardware_tests
UI/UX Guidelines
Theme Support Best Practices
ALWAYS test components in both light and dark themes to ensure proper contrast and visibility.
Container Components (Dialog, Card, Popover, etc.):
- Use
bg-card+text-card-foregroundfor containers with content - Use
bg-backgroundonly for page/base backgrounds - Use
bg-mutedfor disabled/readonly states withtext-foreground(nottext-muted-foreground)
Common Pitfalls:
// ❌ WRONG - Background color on containers makes them blend in
<DialogContent className="bg-background" />
// ✅ CORRECT - Card color creates proper visual hierarchy
<DialogContent className="bg-card text-card-foreground" />
// ❌ WRONG - Muted text on readonly inputs is hard to read
<input className="bg-muted text-muted-foreground" readOnly />
// ✅ CORRECT - Muted background with foreground text
<input className="bg-muted/50 text-foreground" readOnly />
Status Messages:
- Add
border border-{color}/20to banners for better visibility in light mode - Use
font-mediumon text for better readability - Ensure hover states use
/70opacity (not/60) for visibility