diff --git a/Cargo.lock b/Cargo.lock index 155b9880..11ff66d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1389,6 +1389,12 @@ dependencies = [ "prost", ] +[[package]] +name = "qrcode" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" + [[package]] name = "quinn" version = "0.11.6" @@ -1909,6 +1915,7 @@ dependencies = [ "encoding_rs", "nix", "pin-project", + "qrcode", "sshx-core", "tokio", "tokio-stream", diff --git a/crates/sshx-core/src/lib.rs b/crates/sshx-core/src/lib.rs index e5e782d4..26136660 100644 --- a/crates/sshx-core/src/lib.rs +++ b/crates/sshx-core/src/lib.rs @@ -10,7 +10,11 @@ use serde::{Deserialize, Serialize}; /// Protocol buffer and gRPC definitions, automatically generated by Tonic. #[allow(missing_docs, non_snake_case)] -#[allow(clippy::derive_partial_eq_without_eq, clippy::result_large_err)] +#[allow( + clippy::derive_partial_eq_without_eq, + clippy::result_large_err, + clippy::double_must_use +)] pub mod proto { tonic::include_proto!("sshx"); diff --git a/crates/sshx/Cargo.toml b/crates/sshx/Cargo.toml index 24453d4e..ae2c114d 100644 --- a/crates/sshx/Cargo.toml +++ b/crates/sshx/Cargo.toml @@ -19,6 +19,7 @@ clap.workspace = true ctr = "0.9.2" encoding_rs = "0.8.31" pin-project = "1.1.3" +qrcode = { version = "0.14.1", default-features = false } sshx-core.workspace = true tokio.workspace = true tokio-stream.workspace = true diff --git a/crates/sshx/src/lib.rs b/crates/sshx/src/lib.rs index 7a95b2cf..f6c66751 100644 --- a/crates/sshx/src/lib.rs +++ b/crates/sshx/src/lib.rs @@ -8,5 +8,6 @@ pub mod controller; pub mod encrypt; +pub mod qr; pub mod runner; pub mod terminal; diff --git a/crates/sshx/src/main.rs b/crates/sshx/src/main.rs index d91f50ec..ee71dfcc 100644 --- a/crates/sshx/src/main.rs +++ b/crates/sshx/src/main.rs @@ -3,7 +3,7 @@ use std::process::ExitCode; use ansi_term::Color::{Cyan, Fixed, Green}; use anyhow::Result; use clap::Parser; -use sshx::{controller::Controller, runner::Runner, terminal::get_default_shell}; +use sshx::{controller::Controller, qr::render_qr, runner::Runner, terminal::get_default_shell}; use tokio::signal; use tracing::error; @@ -31,9 +31,17 @@ struct Args { /// editors. #[clap(long)] enable_readers: bool, + + /// Print a QR code for the session link in the terminal. + #[clap(long)] + qr: bool, + + /// Print a QR code for the writable link when --enable-readers is active. + #[clap(long)] + qr_write: bool, } -fn print_greeting(shell: &str, controller: &Controller) { +fn print_greeting(shell: &str, controller: &Controller, qr: bool, qr_write: bool) { let version_str = match option_env!("CARGO_PKG_VERSION") { Some(version) => format!("v{version}"), None => String::from("[dev]"), @@ -54,6 +62,21 @@ fn print_greeting(shell: &str, controller: &Controller) { link_e = Cyan.underline().paint(write_url), shell_v = Fixed(8).paint(shell), ); + if qr { + println!( + " {arr} Scan for read-only link:\n", + arr = Green.paint("➜") + ); + if let Some(code) = render_qr(controller.url()) { + println!("{code}\n"); + } + } + if qr_write { + println!(" {arr} Scan for writable link:\n", arr = Green.paint("➜")); + if let Some(code) = render_qr(write_url) { + println!("{code}\n"); + } + } } else { println!( r#" @@ -68,6 +91,12 @@ fn print_greeting(shell: &str, controller: &Controller) { link_v = Cyan.underline().paint(controller.url()), shell_v = Fixed(8).paint(shell), ); + if qr || qr_write { + println!(" {arr} Scan for link:\n", arr = Green.paint("➜")); + if let Some(code) = render_qr(controller.url()) { + println!("{code}\n"); + } + } } } @@ -98,7 +127,7 @@ async fn start(args: Args) -> Result<()> { println!("{}", controller.url()); } } else { - print_greeting(&shell, &controller); + print_greeting(&shell, &controller, args.qr, args.qr_write); } let exit_signal = signal::ctrl_c(); diff --git a/crates/sshx/src/qr.rs b/crates/sshx/src/qr.rs new file mode 100644 index 00000000..af273c2d --- /dev/null +++ b/crates/sshx/src/qr.rs @@ -0,0 +1,91 @@ +//! QR code generation for terminal output. + +use ansi_term::Color; +use qrcode::render::unicode::Dense1x2; +use qrcode::QrCode; + +/// Render an ANSI-styled QR code for a URL, with optional line prefix indentation. +/// +/// Uses Unicode half-block characters (`Dense1x2`) with explicit black-on-white +/// coloring so the QR code remains readable and scannable across both dark and +/// light terminal color schemes. +/// +/// Returns `None` if the input cannot be encoded into a QR code. +pub fn render_qr_with_prefix(url: &str, prefix: &str) -> Option { + let code = QrCode::new(url.as_bytes()).ok()?; + let image = code + .render::() + .dark_color(Dense1x2::Light) + .light_color(Dense1x2::Dark) + .quiet_zone(true) + .build(); + + let styled = image + .lines() + .map(|line| format!("{prefix}{}", Color::White.on(Color::Black).paint(line))) + .collect::>() + .join("\n"); + + Some(styled) +} + +/// Render an ASCII/ANSI QR code for a given URL string indented by 2 spaces. +pub fn render_qr(url: &str) -> Option { + render_qr_with_prefix(url, " ") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_render_qr_standard_url() { + let url = "https://sshx.io/s/abc1234567#key1234567890"; + let rendered = render_qr(url); + assert!(rendered.is_some()); + let output = rendered.unwrap(); + assert!(!output.is_empty()); + // Check that lines have indentation and ANSI codes. + for line in output.lines() { + assert!(line.starts_with(" ")); + assert!(line.contains("\x1b[40;37m")); + } + } + + #[test] + fn test_render_qr_with_prefix() { + let url = "https://sshx.io/s/test#123"; + let prefix = ">>> "; + let rendered = render_qr_with_prefix(url, prefix); + assert!(rendered.is_some()); + let output = rendered.unwrap(); + for line in output.lines() { + assert!(line.starts_with(prefix)); + } + } + + #[test] + fn test_render_qr_empty() { + // Empty string can still be encoded as a valid minimal QR code. + let rendered = render_qr(""); + assert!(rendered.is_some()); + } + + #[test] + fn test_render_qr_read_write_urls() { + let read_url = "https://sshx.io/s/abcdefghij#0123456789abcd"; + let write_url = "https://sshx.io/s/abcdefghij#0123456789abcd,9876543210zyxw"; + + let qr_read = render_qr(read_url).expect("failed to render read-only qr"); + let qr_write = render_qr(write_url).expect("failed to render writable qr"); + + assert!(!qr_read.is_empty()); + assert!(!qr_write.is_empty()); + + // Check line counts: should be around 17-21 lines (compact for standard 80x24 terminal) + let lines_read = qr_read.lines().count(); + let lines_write = qr_write.lines().count(); + assert!(lines_read <= 22, "QR code height too large: {lines_read}"); + assert!(lines_write <= 22, "QR code height too large: {lines_write}"); + } +}