Skip to content
Merged
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
1 change: 1 addition & 0 deletions Cargo.lock

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

3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -63,4 +64,4 @@ missing_errors_doc = "allow"
missing_panics_doc = "allow"

[lints.rust]
unsafe_code = "forbid"
unsafe_code = "deny"
8 changes: 7 additions & 1 deletion src/cli/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
40 changes: 39 additions & 1 deletion src/cli/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()));
}
Expand Down Expand Up @@ -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 <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(
Expand Down Expand Up @@ -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,
};
Expand Down Expand Up @@ -817,6 +842,19 @@ mod tests {
assert!(rendered.contains("inspect --device <selector>"));
}

#[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 <selector>..."));
}

#[test]
fn format_no_receivers_error_uses_info_block() {
let rendered = format_error(&RairstreamError::NoReceiversDiscovered, plain_theme());
Expand Down
44 changes: 41 additions & 3 deletions src/cli/parse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ use std::path::PathBuf;

use crate::error::RairstreamError;

pub(crate) const CLI_USAGE: &str = "usage: discover | inspect --device <selector> | pair --device <selector> [--pin <PIN>] | paired list | paired forget --device <selector> | play file <path> --device <selector>... | play capture --device <selector>...";
pub(crate) const CLI_USAGE: &str =
"usage: rairstream [-h|--help] [-v|-vv] [--log-level <error|warn|info|debug|trace>] <command>";
pub(crate) const CLI_USAGE_HEADER: &str =
"rairstream [-v|-vv] [--log-level <error|warn|info|debug|trace>] <command>";
"rairstream [-h|--help] [-v|-vv] [--log-level <error|warn|info|debug|trace>] <command>";
pub(crate) const CLI_COMMAND_USAGE: &[&str] = &[
"discover",
"inspect --device <selector>",
Expand All @@ -19,6 +20,7 @@ pub(crate) const CLI_COMMAND_USAGE: &[&str] = &[

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CliCommand {
Help,
Discover,
Inspect {
selector: String,
Expand Down Expand Up @@ -63,9 +65,11 @@ pub fn parse_cli(args: impl IntoIterator<Item = String>) -> Result<CliOptions, R
let mut verbosity = 0_u8;
let mut log_level = None;
let mut positionals = Vec::new();
let mut help_requested = false;

while let Some(arg) = args.next() {
match arg.as_str() {
"-h" | "--help" => help_requested = true,
"-v" | "--verbose" => verbosity = verbosity.saturating_add(1),
"-vv" => verbosity = verbosity.saturating_add(2),
"--log-level" => {
Expand All @@ -83,7 +87,17 @@ pub fn parse_cli(args: impl IntoIterator<Item = String>) -> Result<CliOptions, R
}
}

let command = parse_command(&positionals)?;
let command = if help_requested {
if positionals.is_empty() {
CliCommand::Help
} else {
return Err(RairstreamError::InvalidCli {
message: String::from("-h/--help cannot be combined with a command"),
});
}
} else {
parse_command(&positionals)?
};
Ok(CliOptions {
command,
log_level,
Expand Down Expand Up @@ -260,6 +274,30 @@ fn missing_value_error(flag: &str) -> 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());
Expand Down
107 changes: 107 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ enum StartupMode {

fn main() {
let args: Vec<String> = 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,
Expand Down Expand Up @@ -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<u16> {
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"))
Expand Down Expand Up @@ -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
));
}
}
Loading