From 45daf142de68b224c3d408dab3fee768fa383dbd Mon Sep 17 00:00:00 2001 From: ByteColtX Date: Mon, 11 May 2026 11:48:11 +0800 Subject: [PATCH] =?UTF-8?q?fix(cli):=20=E4=BF=AE=E5=A4=8D=20windows=20?= =?UTF-8?q?=E6=8E=A7=E5=88=B6=E5=8F=B0=E8=BE=93=E5=87=BA=E4=B8=8E=20help?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 1 + Cargo.toml | 3 +- src/cli/commands.rs | 8 +++- src/cli/output.rs | 40 ++++++++++++++++- src/cli/parse.rs | 44 ++++++++++++++++-- src/main.rs | 107 ++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 197 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 12d3359..8519d36 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2393,6 +2393,7 @@ dependencies = [ "tracing-subscriber", "tray-icon", "wasapi", + "windows-sys 0.61.2", "winit", "winresource", "x25519-dalek", diff --git a/Cargo.toml b/Cargo.toml index 4e48edd..82bd542 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,6 +45,7 @@ x25519-dalek = "2.0.1" native-dialog = "0.9.6" tray-icon = { version = "0.23.1", default-features = false } wasapi = "0.23.0" +windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_Console"] } winit = { version = "0.30.12", default-features = false } [build-dependencies] @@ -63,4 +64,4 @@ missing_errors_doc = "allow" missing_panics_doc = "allow" [lints.rust] -unsafe_code = "forbid" +unsafe_code = "deny" diff --git a/src/cli/commands.rs b/src/cli/commands.rs index 879112c..c395b27 100644 --- a/src/cli/commands.rs +++ b/src/cli/commands.rs @@ -5,16 +5,22 @@ use crate::discovery::MdnsDiscoveryService; use crate::error::RairstreamError; use super::output::{ - print_inspect, print_paired_list, print_paired_removed, print_paired_saved, + print_help, print_inspect, print_paired_list, print_paired_removed, print_paired_saved, print_pairing_pin_requested, print_play_capture_started, print_play_capture_stopped, print_play_file_completed, print_receivers, }; use super::parse::{CliCommand, CliOptions}; pub fn run_cli(cli: CliOptions) -> Result<(), RairstreamError> { + if cli.command == CliCommand::Help { + print_help(); + return Ok(()); + } + let mut facade = AppFacade::new(MdnsDiscoveryService::default())?; match cli.command { + CliCommand::Help => unreachable!("help exits before app initialization"), CliCommand::Discover => { let receivers = facade.discover()?; print_receivers(&receivers); diff --git a/src/cli/output.rs b/src/cli/output.rs index 51c61a4..eaecbde 100644 --- a/src/cli/output.rs +++ b/src/cli/output.rs @@ -57,6 +57,10 @@ pub fn print_play_capture_stopped(selectors: &[String]) { write_stdout(&format_play_capture_stopped(selectors, Theme::stdout())); } +pub fn print_help() { + write_stdout(&format_help(Theme::stdout())); +} + pub fn print_error(error: &RairstreamError) { write_stderr(&format_error(error, Theme::stderr())); } @@ -321,6 +325,27 @@ fn format_play_capture_stopped(selectors: &[String], theme: Theme) -> String { ) } +fn format_help(theme: Theme) -> String { + let mut lines = Vec::new(); + lines.push(theme.section_title("Usage")); + lines.push(format!(" {CLI_USAGE_HEADER}")); + lines.push(String::new()); + lines.push(theme.section_title("Commands")); + lines.extend(CLI_COMMAND_USAGE.iter().map(|line| format!(" {line}"))); + lines.push(String::new()); + lines.push(theme.section_title("Options")); + lines.push(String::from(" -h, --help Show this help text")); + lines.push(String::from(" -v, --verbose Increase log verbosity")); + lines.push(String::from( + " -vv Enable trace-level verbosity", + )); + lines.push(String::from( + " --log-level Set error, warn, info, debug, or trace logging", + )); + + render_card(&theme.title("Rairstream CLI"), lines) +} + fn format_receiver(receiver: &Receiver, theme: Theme) -> String { let header = format_receiver_title(receiver, theme); render_card( @@ -642,7 +667,7 @@ mod tests { use std::path::Path; use super::{ - Theme, format_codecs, format_error, format_inspect, format_paired_removed, + Theme, format_codecs, format_error, format_help, format_inspect, format_paired_removed, format_pairing_pin_requested, format_play_capture_started, format_receiver, format_receivers, }; @@ -817,6 +842,19 @@ mod tests { assert!(rendered.contains("inspect --device ")); } + #[test] + fn format_help_includes_usage_commands_and_options() { + let rendered = format_help(plain_theme()); + + assert!(rendered.contains("Rairstream CLI")); + assert!(!rendered.contains("ℹ Rairstream CLI")); + assert!(rendered.contains("Usage")); + assert!(rendered.contains("Commands")); + assert!(rendered.contains("Options")); + assert!(rendered.contains("-h, --help")); + assert!(rendered.contains("play capture --device ...")); + } + #[test] fn format_no_receivers_error_uses_info_block() { let rendered = format_error(&RairstreamError::NoReceiversDiscovered, plain_theme()); diff --git a/src/cli/parse.rs b/src/cli/parse.rs index b85affa..cf03ca1 100644 --- a/src/cli/parse.rs +++ b/src/cli/parse.rs @@ -4,9 +4,10 @@ use std::path::PathBuf; use crate::error::RairstreamError; -pub(crate) const CLI_USAGE: &str = "usage: discover | inspect --device | pair --device [--pin ] | paired list | paired forget --device | play file --device ... | play capture --device ..."; +pub(crate) const CLI_USAGE: &str = + "usage: rairstream [-h|--help] [-v|-vv] [--log-level ] "; pub(crate) const CLI_USAGE_HEADER: &str = - "rairstream [-v|-vv] [--log-level ] "; + "rairstream [-h|--help] [-v|-vv] [--log-level ] "; pub(crate) const CLI_COMMAND_USAGE: &[&str] = &[ "discover", "inspect --device ", @@ -19,6 +20,7 @@ pub(crate) const CLI_COMMAND_USAGE: &[&str] = &[ #[derive(Debug, Clone, PartialEq, Eq)] pub enum CliCommand { + Help, Discover, Inspect { selector: String, @@ -63,9 +65,11 @@ pub fn parse_cli(args: impl IntoIterator) -> Result help_requested = true, "-v" | "--verbose" => verbosity = verbosity.saturating_add(1), "-vv" => verbosity = verbosity.saturating_add(2), "--log-level" => { @@ -83,7 +87,17 @@ pub fn parse_cli(args: impl IntoIterator) -> Result RairstreamError { mod tests { use super::{CliCommand, parse_cli}; + #[test] + fn parse_help_short_flag() { + let cli = parse_cli([String::from("-h")]).unwrap(); + + assert_eq!(cli.command, CliCommand::Help); + } + + #[test] + fn parse_help_long_flag() { + let cli = parse_cli([String::from("--help")]).unwrap(); + + assert_eq!(cli.command, CliCommand::Help); + } + + #[test] + fn parse_help_rejects_command_combination() { + let error = parse_cli([String::from("--help"), String::from("discover")]).unwrap_err(); + + assert_eq!( + error.to_string(), + "invalid command line: -h/--help cannot be combined with a command" + ); + } + #[test] fn parse_play_capture_requires_device() { assert!(parse_cli([String::from("play"), String::from("capture")]).is_err()); diff --git a/src/main.rs b/src/main.rs index ce1cee7..fd2a449 100644 --- a/src/main.rs +++ b/src/main.rs @@ -24,6 +24,11 @@ enum StartupMode { fn main() { let args: Vec = std::env::args().skip(1).collect(); + #[cfg(target_os = "windows")] + if !args.is_empty() { + attach_parent_console_for_cli(); + } + let startup_mode = match determine_startup_mode(&args, std::env::var_os(TRAY_BACKGROUND_ENV).is_some()) { Ok(mode) => mode, @@ -117,6 +122,98 @@ fn spawn_tray_background() -> Result<(), String> { Ok(()) } +#[cfg(target_os = "windows")] +#[allow(unsafe_code)] +fn attach_parent_console_for_cli() { + use std::ptr::null; + use windows_sys::Win32::Foundation::{ + ERROR_ACCESS_DENIED, GENERIC_READ, GENERIC_WRITE, GetLastError, HANDLE, + INVALID_HANDLE_VALUE, + }; + use windows_sys::Win32::Storage::FileSystem::{ + CreateFileW, FILE_ATTRIBUTE_NORMAL, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING, + }; + use windows_sys::Win32::System::Console::{ + ATTACH_PARENT_PROCESS, AttachConsole, GetStdHandle, STD_ERROR_HANDLE, STD_INPUT_HANDLE, + STD_OUTPUT_HANDLE, SetStdHandle, + }; + + fn is_invalid_handle(handle: HANDLE) -> bool { + handle.is_null() || handle == INVALID_HANDLE_VALUE + } + + fn wide(value: &str) -> Vec { + value.encode_utf16().chain(std::iter::once(0)).collect() + } + + #[allow(unsafe_code)] + // SAFETY: These calls only attach this Windows GUI-subsystem process to the + // parent's console and replace missing standard handles with CONIN$/CONOUT$. + // Existing valid handles are preserved so shell redirection keeps working. + unsafe { + let stdout_missing = is_invalid_handle(GetStdHandle(STD_OUTPUT_HANDLE)); + let stderr_missing = is_invalid_handle(GetStdHandle(STD_ERROR_HANDLE)); + let stdin_missing = is_invalid_handle(GetStdHandle(STD_INPUT_HANDLE)); + if !stdout_missing && !stderr_missing && !stdin_missing { + return; + } + + if AttachConsole(ATTACH_PARENT_PROCESS) == 0 && GetLastError() != ERROR_ACCESS_DENIED { + return; + } + + if stdout_missing { + let output = CreateFileW( + wide("CONOUT$").as_ptr(), + GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE, + null(), + OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL, + null_mut_handle(), + ); + if !is_invalid_handle(output) { + let _ = SetStdHandle(STD_OUTPUT_HANDLE, output); + } + } + + if stderr_missing { + let output = CreateFileW( + wide("CONOUT$").as_ptr(), + GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE, + null(), + OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL, + null_mut_handle(), + ); + if !is_invalid_handle(output) { + let _ = SetStdHandle(STD_ERROR_HANDLE, output); + } + } + + if stdin_missing { + let input = CreateFileW( + wide("CONIN$").as_ptr(), + GENERIC_READ, + FILE_SHARE_READ | FILE_SHARE_WRITE, + null(), + OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL, + null_mut_handle(), + ); + if !is_invalid_handle(input) { + let _ = SetStdHandle(STD_INPUT_HANDLE, input); + } + } + } +} + +#[cfg(target_os = "windows")] +const fn null_mut_handle() -> windows_sys::Win32::Foundation::HANDLE { + std::ptr::null_mut() +} + #[cfg(not(target_os = "windows"))] fn spawn_tray_background() -> Result<(), String> { Err(String::from("tray background bootstrap is unsupported")) @@ -163,4 +260,14 @@ mod tests { StartupMode::Cli(cli) if cli.command == CliCommand::Discover )); } + + #[test] + fn help_arguments_parse_through_cli_mode() { + let mode = determine_startup_mode(&[String::from("--help")], false).unwrap(); + + assert!(matches!( + mode, + StartupMode::Cli(cli) if cli.command == CliCommand::Help + )); + } }