Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion crates/sshx-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down
1 change: 1 addition & 0 deletions crates/sshx/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions crates/sshx/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@

pub mod controller;
pub mod encrypt;
pub mod qr;
pub mod runner;
pub mod terminal;
35 changes: 32 additions & 3 deletions crates/sshx/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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]"),
Expand All @@ -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#"
Expand All @@ -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");
}
}
}
}

Expand Down Expand Up @@ -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();
Expand Down
91 changes: 91 additions & 0 deletions crates/sshx/src/qr.rs
Original file line number Diff line number Diff line change
@@ -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<String> {
let code = QrCode::new(url.as_bytes()).ok()?;
let image = code
.render::<Dense1x2>()
.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::<Vec<_>>()
.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<String> {
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}");
}
}
Loading