From 916c69c4a23961d1d2620394a989208b2f84ba97 Mon Sep 17 00:00:00 2001 From: sanchxt Date: Sun, 11 Jan 2026 23:11:58 +0530 Subject: [PATCH] feat(qr): add QR code support for share codes Implement QR code generation and display for share codes in both CLI and web interface. QR codes encode deep links (yoop://CODE) for future mobile app integration. - Add qr module with ASCII, SVG, and PNG generation - Display QR codes in CLI terminal output - Render QR codes in web interface - Add ui.show_qr config toggle (default: false) - Add /api/share/qr endpoint for direct SVG access QR codes are disabled by default as mobile app is not yet implemented. --- README.md | 158 +++++++++--- crates/yoop-cli/src/commands/share.rs | 8 +- crates/yoop-cli/src/ui.rs | 25 +- crates/yoop-core/src/config/mod.rs | 2 +- crates/yoop-core/src/lib.rs | 2 + crates/yoop-core/src/qr/mod.rs | 285 +++++++++++++++++++++ crates/yoop-core/src/web/assets/app.js | 34 ++- crates/yoop-core/src/web/assets/index.html | 6 + crates/yoop-core/src/web/assets/style.css | 30 +++ crates/yoop-core/src/web/handlers.rs | 28 ++ crates/yoop-core/src/web/mod.rs | 2 + 11 files changed, 541 insertions(+), 39 deletions(-) create mode 100644 crates/yoop-core/src/qr/mod.rs diff --git a/README.md b/README.md index 127d0db..2ba82d5 100644 --- a/README.md +++ b/README.md @@ -15,13 +15,15 @@ Yoop enables seamless peer-to-peer file transfers over local networks using simp - **Cross-platform**: Works on Windows, Linux, and macOS - **No account required**: Zero configuration, no cloud dependency - **Simple 4-character codes**: Easy discovery without IP addresses +- **QR code support**: Display scannable codes for upcoming mobile app (experimental) - **Dual discovery**: UDP broadcast + mDNS/DNS-SD for reliable device discovery - **Private & secure**: TLS 1.3 encryption, data never leaves local network - **Fast transfers**: Chunked transfers with xxHash64 verification - **Resume capability**: Interrupted transfers can be resumed automatically -- **CLI interface**: Full-featured command-line tool -- **Web interface**: Browser-based UI for devices without CLI access +- **CLI + Web interface**: Full-featured command-line tool and browser-based UI +- **Trusted devices**: Ed25519 signature-based authentication for direct transfers - **Clipboard sharing**: One-shot transfer and live bidirectional sync +- **Shell completions**: Bash, Zsh, Fish, PowerShell, Elvish support ## Quick Start @@ -51,24 +53,38 @@ yoop receive A7K9 --output ~/Downloads/ yoop receive A7K9 --batch ``` -### Clipboard Sharing +### Clipboard Sharing (Unique Feature!) ```bash -# Share current clipboard content (generates a code) -yoop clipboard share +# One-shot clipboard sharing +yoop clipboard share # Share current clipboard +yoop clipboard receive A7K9 # Receive clipboard content -# Receive clipboard content using a code -yoop clipboard receive A7K9 - -# Start bidirectional clipboard sync (host) -yoop clipboard sync - -# Join existing sync session -yoop clipboard sync A7K9 +# Live bidirectional sync (sync clipboard changes in real-time) +yoop clipboard sync # Host sync session +yoop clipboard sync A7K9 # Join sync session ``` +Supports text and images. Changes sync automatically across devices! + ## Installation +### via npm (Recommended) + +```bash +# npm +npm install -g yoop + +# pnpm +pnpm add -g yoop + +# yarn +yarn global add yoop + +# bun +bun add -g yoop +``` + ### From Source Requires **Rust 1.86.0** or later. @@ -81,10 +97,24 @@ cargo install --path crates/yoop-cli ### Pre-built Binaries -Pre-built binaries and package manager support are planned for future releases. +Pre-built binaries for Windows, Linux, and macOS are coming soon. + +## Shell Completions + +Install tab completions for your shell: + +```bash +yoop completions install # Auto-detect shell and install +yoop completions install --shell zsh +yoop completions generate bash # Print to stdout +``` + +Supported: Bash, Zsh, Fish, PowerShell, Elvish ## How It Works +**Code-based transfers:** + 1. **Sender** shares files and gets a 4-character code (e.g., `A 7 K 9`) 2. **Receiver** enters the code on their device 3. **Discovery** happens via UDP broadcast + mDNS on local network @@ -92,6 +122,8 @@ Pre-built binaries and package manager support are planned for future releases. 5. **Verification** using xxHash64 per chunk, SHA-256 for complete file 6. **Resume** automatic resumption of interrupted transfers from last checkpoint +**For trusted devices:** Direct connection using Ed25519 signatures (no code needed) + ``` ┌─────────────┐ UDP Broadcast ┌──────────────┐ │ Sender │ ◄──────── Code: A7K9 ──────────► │ Receiver │ @@ -105,26 +137,68 @@ Pre-built binaries and package manager support are planned for future releases. ```bash # Sharing & Receiving -yoop share # Share files/folders -yoop receive # Receive with code +yoop share # Share files/folders +yoop receive # Receive with code +yoop send # Send to trusted device (no code) # Clipboard Sharing -yoop clipboard share # Share clipboard content -yoop clipboard receive # Receive clipboard content -yoop clipboard sync [code] # Bidirectional clipboard sync - -# Utilities -yoop scan # Scan for active shares on network -yoop web # Start web interface -yoop config # Manage configuration -yoop diagnose # Network diagnostics -yoop history # View transfer history - -# Planned Features -yoop send # Send to trusted device (in development) -yoop trust list # Manage trusted devices (in development) +yoop clipboard share # Share clipboard content +yoop clipboard receive # Receive clipboard content +yoop clipboard sync [code] # Bidirectional clipboard sync + +# Device & Network Management +yoop trust list # Manage trusted devices +yoop scan # Scan for active shares +yoop diagnose # Network diagnostics + +# Configuration & Utilities +yoop config # Manage configuration +yoop history # View transfer history +yoop web # Start web interface +yoop completions install # Install shell completions +``` + +## Web Interface + +Start a browser-based UI for devices without CLI access: + +```bash +yoop web # Start on default port 8080 +yoop web --port 9000 # Custom port +yoop web --auth # Require authentication +yoop web --localhost-only # Bind to localhost only +``` + +**Features:** + +- Drag-and-drop file sharing +- QR codes with deep links (for future mobile app integration) +- File previews (images, text, archives) +- Real-time transfer progress +- No installation required (just open in browser) + +Access at `http://[your-ip]:8080` from any device on the network. + +## Trusted Devices + +Send files directly to trusted devices without share codes: + +```bash +# First transfer: Use share code +yoop share file.txt +# After accepting, you'll be prompted to trust the device + +# Subsequent transfers: Direct send (no code needed) +yoop send "Device-Name" file.txt + +# Manage trusted devices +yoop trust list # List all trusted devices +yoop trust set "Name" --level full # Set trust level +yoop trust remove "Name" # Remove device ``` +**Security:** Uses Ed25519 signatures for authentication. No MITM attacks possible. + ## Configuration Yoop can be configured via TOML files: @@ -139,6 +213,7 @@ Example configuration: [general] device_name = "My-Laptop" default_expire = "5m" +default_output = "~/Downloads" [network] port = 52525 @@ -152,8 +227,26 @@ verify_checksum = true [security] tls_verify = true rate_limit_attempts = 3 + +[trust] +enabled = true +auto_prompt = true +default_level = "ask_each_time" + +[history] +enabled = true +max_entries = 100 + +[ui] +show_qr = false # Enable QR codes (for future mobile app) + +[web] +port = 8080 +auth = false ``` +See all options: `yoop config list` + ## Development ### Prerequisites @@ -189,6 +282,8 @@ cargo test --lib --workspace # Integration tests only cargo test --test integration_transfer +cargo test --test integration_trust +cargo test --test integration_clipboard # With output cargo test -- --nocapture @@ -205,6 +300,9 @@ cargo clippy --workspace -- -D warnings # Check without building cargo check --workspace + +# Generate documentation +cargo doc --workspace --open ``` ## Architecture diff --git a/crates/yoop-cli/src/commands/share.rs b/crates/yoop-cli/src/commands/share.rs index 564b16d..b1ba46e 100644 --- a/crates/yoop-cli/src/commands/share.rs +++ b/crates/yoop-cli/src/commands/share.rs @@ -49,7 +49,7 @@ pub async fn run(args: ShareArgs) -> Result<()> { let total_size: u64 = files.iter().map(|f| f.size).sum(); let code = session.code().to_string(); - display_share_info(&files, total_size, &code, &args)?; + display_share_info(&files, total_size, &code, &args, &global_config)?; let progress_rx = session.progress(); let expire_duration = @@ -99,6 +99,7 @@ fn display_share_info( total_size: u64, code: &str, args: &ShareArgs, + global_config: &yoop_core::config::Config, ) -> Result<()> { let total_files = files.len(); @@ -128,7 +129,10 @@ fn display_share_info( }); println!("{}", serde_json::to_string_pretty(&output)?); } else if !args.quiet { - CodeBox::new(code).with_expire(&args.expire).display(); + CodeBox::new(code) + .with_expire(&args.expire) + .with_qr(global_config.ui.show_qr) + .display(); println!(); } diff --git a/crates/yoop-cli/src/ui.rs b/crates/yoop-cli/src/ui.rs index c08ea2c..c620a64 100644 --- a/crates/yoop-cli/src/ui.rs +++ b/crates/yoop-cli/src/ui.rs @@ -8,13 +8,18 @@ const BOX_WIDTH: usize = 33; pub struct CodeBox<'a> { code: &'a str, expire: Option<&'a str>, + show_qr: bool, } impl<'a> CodeBox<'a> { /// Create a new code box. #[must_use] pub const fn new(code: &'a str) -> Self { - Self { code, expire: None } + Self { + code, + expire: None, + show_qr: false, + } } /// Add expiration time to the box. @@ -24,6 +29,13 @@ impl<'a> CodeBox<'a> { self } + /// Enable QR code display. + #[must_use] + pub const fn with_qr(mut self, show: bool) -> Self { + self.show_qr = show; + self + } + /// Display the code box to stdout. pub fn display(&self) { let spaced_code = format_code_spaced(self.code); @@ -41,6 +53,17 @@ impl<'a> CodeBox<'a> { } println!(" └{}┘", "─".repeat(BOX_WIDTH)); + + if self.show_qr { + if let Ok(qr) = yoop_core::qr::generate_ascii(self.code) { + println!(); + for line in qr.lines() { + println!(" {}", line); + } + println!(); + println!(" Scan to receive: yoop://{}", self.code); + } + } } } diff --git a/crates/yoop-core/src/config/mod.rs b/crates/yoop-core/src/config/mod.rs index f5bcfc8..7ff8405 100644 --- a/crates/yoop-core/src/config/mod.rs +++ b/crates/yoop-core/src/config/mod.rs @@ -310,7 +310,7 @@ impl Default for UiConfig { fn default() -> Self { Self { theme: "auto".to_string(), - show_qr: true, + show_qr: false, notifications: true, sound: true, } diff --git a/crates/yoop-core/src/lib.rs b/crates/yoop-core/src/lib.rs index 269ccee..f3605a9 100644 --- a/crates/yoop-core/src/lib.rs +++ b/crates/yoop-core/src/lib.rs @@ -21,6 +21,7 @@ //! - [`history`] - Transfer history tracking and persistence //! - [`preview`] - File preview generation (thumbnails, text snippets) //! - [`protocol`] - LDRP wire protocol implementation +//! - [`qr`] - QR code generation for share codes //! - [`transfer`] - File transfer engine //! - [`trust`] - Trusted devices management //! - [`web`] - Embedded web server for browser-based access @@ -63,6 +64,7 @@ pub mod file; pub mod history; pub mod preview; pub mod protocol; +pub mod qr; pub mod transfer; pub mod trust; diff --git a/crates/yoop-core/src/qr/mod.rs b/crates/yoop-core/src/qr/mod.rs new file mode 100644 index 0000000..2494171 --- /dev/null +++ b/crates/yoop-core/src/qr/mod.rs @@ -0,0 +1,285 @@ +//! QR code generation for Yoop share codes. +//! +//! This module generates QR codes containing deep links for mobile scanning. +//! +//! ## Features +//! +//! - ASCII art QR for terminal display +//! - SVG QR for web interface +//! - Configurable URL scheme for deep links +//! +//! ## Example +//! +//! ```rust,ignore +//! use yoop_core::qr; +//! +//! let ascii = qr::generate_ascii("A7K9")?; +//! println!("{}", ascii); +//! +//! let svg = qr::generate_svg("A7K9")?; +//! ``` + +use base64::Engine; +use qrcode::render::{svg, unicode}; +use qrcode::{EcLevel, QrCode}; + +use crate::error::{Error, Result}; + +/// Configuration for QR code generation. +#[derive(Debug, Clone)] +pub struct QrConfig { + /// URL scheme for deep links (default: "yoop") + pub scheme: String, + /// Error correction level (default: Medium) + pub error_correction: EcLevel, +} + +impl Default for QrConfig { + fn default() -> Self { + Self { + scheme: "yoop".to_string(), + error_correction: EcLevel::M, + } + } +} + +/// Create a deep link URL from a share code. +/// +/// # Arguments +/// +/// * `code` - The share code (e.g., "A7K9") +/// * `config` - QR configuration +/// +/// # Returns +/// +/// A deep link URL (e.g., "yoop://A7K9") +/// +/// # Example +/// +/// ``` +/// use yoop_core::qr::{create_deep_link, QrConfig}; +/// +/// let link = create_deep_link("A7K9", &QrConfig::default()); +/// assert_eq!(link, "yoop://A7K9"); +/// ``` +#[must_use] +pub fn create_deep_link(code: &str, config: &QrConfig) -> String { + format!("{}://{}", config.scheme, code.to_uppercase()) +} + +/// Generate ASCII art QR code for terminal display. +/// +/// Uses Unicode block characters for compact display in terminals. +/// +/// # Arguments +/// +/// * `code` - The share code (e.g., "A7K9") +/// +/// # Errors +/// +/// Returns an error if QR code generation fails. +/// +/// # Example +/// +/// ``` +/// use yoop_core::qr::generate_ascii; +/// +/// let qr = generate_ascii("A7K9").unwrap(); +/// println!("{}", qr); +/// ``` +pub fn generate_ascii(code: &str) -> Result { + let deep_link = create_deep_link(code, &QrConfig::default()); + + let qr_code = QrCode::with_error_correction_level(&deep_link, EcLevel::M) + .map_err(|e| Error::Internal(format!("Failed to generate QR code: {e}")))?; + + let rendered = qr_code + .render::() + .dark_color(unicode::Dense1x2::Light) + .light_color(unicode::Dense1x2::Dark) + .build(); + + Ok(rendered) +} + +/// Generate SVG QR code for web interface. +/// +/// Returns an SVG string that can be embedded in HTML. +/// +/// # Arguments +/// +/// * `code` - The share code (e.g., "A7K9") +/// +/// # Errors +/// +/// Returns an error if QR code generation fails. +/// +/// # Example +/// +/// ``` +/// use yoop_core::qr::generate_svg; +/// +/// let svg = generate_svg("A7K9").unwrap(); +/// assert!(svg.contains("")); +/// ``` +pub fn generate_svg(code: &str) -> Result { + let deep_link = create_deep_link(code, &QrConfig::default()); + + let qr_code = QrCode::with_error_correction_level(&deep_link, EcLevel::M) + .map_err(|e| Error::Internal(format!("Failed to generate QR code: {e}")))?; + + let svg_string = qr_code + .render::() + .min_dimensions(200, 200) + .dark_color(svg::Color("#000000")) + .light_color(svg::Color("#ffffff")) + .build(); + + Ok(svg_string) +} + +/// Generate base64-encoded PNG QR code. +/// +/// Useful for embedding in web pages as data URLs. +/// +/// # Arguments +/// +/// * `code` - The share code (e.g., "A7K9") +/// * `size` - Size of the QR code in pixels +/// +/// # Errors +/// +/// Returns an error if QR code generation or encoding fails. +/// +/// # Example +/// +/// ``` +/// use yoop_core::qr::generate_png_base64; +/// +/// let png_data = generate_png_base64("A7K9", 256).unwrap(); +/// assert!(!png_data.is_empty()); +/// ``` +pub fn generate_png_base64(code: &str, size: u32) -> Result { + use image::Luma; + + let deep_link = create_deep_link(code, &QrConfig::default()); + + let qr_code = QrCode::with_error_correction_level(&deep_link, EcLevel::M) + .map_err(|e| Error::Internal(format!("Failed to generate QR code: {e}")))?; + + let image = qr_code.render::>().build(); + + let scaled = image::imageops::resize(&image, size, size, image::imageops::FilterType::Nearest); + + let mut png_bytes = Vec::new(); + { + use image::ImageEncoder; + let encoder = image::codecs::png::PngEncoder::new(&mut png_bytes); + encoder + .write_image(&scaled, size, size, image::ExtendedColorType::L8) + .map_err(|e| Error::Internal(format!("Failed to encode PNG: {e}")))?; + } + + Ok(base64::prelude::BASE64_STANDARD.encode(&png_bytes)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_create_deep_link_default() { + let link = create_deep_link("A7K9", &QrConfig::default()); + assert_eq!(link, "yoop://A7K9"); + } + + #[test] + fn test_create_deep_link_uppercase() { + let link = create_deep_link("a7k9", &QrConfig::default()); + assert_eq!(link, "yoop://A7K9"); + } + + #[test] + fn test_create_deep_link_custom_scheme() { + let config = QrConfig { + scheme: "localdrop".to_string(), + ..Default::default() + }; + let link = create_deep_link("A7K9", &config); + assert_eq!(link, "localdrop://A7K9"); + } + + #[test] + fn test_generate_ascii_not_empty() { + let qr = generate_ascii("A7K9").unwrap(); + assert!(!qr.is_empty()); + assert!(qr.contains('█') || qr.contains('▀') || qr.contains('▄')); + } + + #[test] + fn test_generate_ascii_multiline() { + let qr = generate_ascii("A7K9").unwrap(); + assert!(qr.lines().count() > 5); + } + + #[test] + fn test_generate_svg_valid_xml() { + let svg = generate_svg("A7K9").unwrap(); + assert!( + svg.starts_with(""), "SVG should have closing tag"); + assert!( + svg.contains("xmlns") || svg.contains("viewBox"), + "SVG should have xmlns or viewBox attribute" + ); + } + + #[test] + fn test_generate_svg_has_dimensions() { + let svg = generate_svg("A7K9").unwrap(); + assert!(svg.contains("width") && svg.contains("height")); + } + + #[test] + fn test_qr_code_generation_succeeds() { + let result = QrCode::new("yoop://A7K9"); + assert!(result.is_ok()); + + let qr = result.unwrap(); + assert!(matches!(qr.version(), qrcode::Version::Normal(_))); + } + + #[test] + fn test_generate_png_base64_not_empty() { + let png = generate_png_base64("A7K9", 256).unwrap(); + assert!(!png.is_empty()); + + let decoded = base64::prelude::BASE64_STANDARD.decode(&png).unwrap(); + assert!(decoded.len() > 100); + } + + #[test] + fn test_different_codes_produce_different_qrs() { + let qr1 = generate_ascii("A7K9").unwrap(); + let qr2 = generate_ascii("B8M3").unwrap(); + assert_ne!(qr1, qr2); + } + + #[test] + fn test_config_error_correction_levels() { + let config_low = QrConfig { + scheme: "yoop".to_string(), + error_correction: EcLevel::L, + }; + let config_high = QrConfig { + scheme: "yoop".to_string(), + error_correction: EcLevel::H, + }; + + assert_eq!(config_low.error_correction, EcLevel::L); + assert_eq!(config_high.error_correction, EcLevel::H); + } +} diff --git a/crates/yoop-core/src/web/assets/app.js b/crates/yoop-core/src/web/assets/app.js index ce8b336..2924b19 100644 --- a/crates/yoop-core/src/web/assets/app.js +++ b/crates/yoop-core/src/web/assets/app.js @@ -24,6 +24,8 @@ const elements = { shareCodeDisplay: document.getElementById("share-code-display"), shareCode: document.getElementById("share-code"), expireTime: document.getElementById("expire-time"), + qrCodeContainer: document.getElementById("qr-code-container"), + qrCode: document.getElementById("qr-code"), shareStatus: document.getElementById("share-status"), btnCancelShare: document.getElementById("btn-cancel-share"), @@ -221,6 +223,8 @@ function resetShareUI() { elements.dropZone.hidden = false; elements.selectedFiles.hidden = state.selectedFiles.length === 0; elements.shareCodeDisplay.hidden = true; + elements.qrCodeContainer.hidden = true; + elements.qrCode.innerHTML = ""; } function resetReceiveUI() { @@ -326,6 +330,13 @@ async function startShare() { elements.selectedFiles.hidden = true; elements.shareCodeDisplay.hidden = false; + if (result.qr_svg) { + elements.qrCode.innerHTML = result.qr_svg; + elements.qrCodeContainer.hidden = false; + } else { + elements.qrCodeContainer.hidden = true; + } + updateExpireTime(); state.expireInterval = setInterval(updateExpireTime, 1000); @@ -371,12 +382,25 @@ async function connectToCode() { let previewHtml = ""; if (file.preview) { - if (file.preview.preview_type === "thumbnail" && file.preview.data) { + if ( + file.preview.preview_type === "thumbnail" && + file.preview.data + ) { previewHtml = `Preview`; - } else if (file.preview.preview_type === "text" && file.preview.data) { - const snippet = file.preview.data.substring(0, 60).replace(/\n/g, " "); - previewHtml = `"${snippet}${file.preview.data.length > 60 ? "..." : ""}"`; - } else if (file.preview.preview_type === "archive" && file.preview.file_count) { + } else if ( + file.preview.preview_type === "text" && + file.preview.data + ) { + const snippet = file.preview.data + .substring(0, 60) + .replace(/\n/g, " "); + previewHtml = `"${snippet}${ + file.preview.data.length > 60 ? "..." : "" + }"`; + } else if ( + file.preview.preview_type === "archive" && + file.preview.file_count + ) { previewHtml = `(${file.preview.file_count} files)`; } } diff --git a/crates/yoop-core/src/web/assets/index.html b/crates/yoop-core/src/web/assets/index.html index deb5e5d..cd93478 100644 --- a/crates/yoop-core/src/web/assets/index.html +++ b/crates/yoop-core/src/web/assets/index.html @@ -44,6 +44,12 @@

Selected Files

Expires in

+ + +

Waiting for receiver...