diff --git a/src/common.rs b/src/common.rs index f38cd49..6663450 100644 --- a/src/common.rs +++ b/src/common.rs @@ -11,7 +11,7 @@ pub(crate) use std::{ hash::Hash, io::{self, Read, Write}, path::{Path, PathBuf}, - process, + process::{self, Command, ExitStatus}, str::{self, FromStr}, time::{SystemTime, SystemTimeError}, usize, @@ -36,13 +36,14 @@ pub(crate) use crate::{bencode, consts, error, torrent, use_color}; // traits pub(crate) use crate::{ - into_u64::IntoU64, into_usize::IntoUsize, path_ext::PathExt, reckoner::Reckoner, + into_u64::IntoU64, into_usize::IntoUsize, path_ext::PathExt, + platform_interface::PlatformInterface, reckoner::Reckoner, }; // structs and enums pub(crate) use crate::{ env::Env, error::Error, file_info::FileInfo, hasher::Hasher, info::Info, metainfo::Metainfo, - mode::Mode, opt::Opt, style::Style, subcommand::Subcommand, torrent::Torrent, + mode::Mode, opt::Opt, platform::Platform, style::Style, subcommand::Subcommand, torrent::Torrent, use_color::UseColor, }; @@ -58,6 +59,7 @@ pub(crate) use std::{ iter, ops::{Deref, DerefMut}, rc::Rc, + time::{Duration, Instant}, }; // test structs and enums diff --git a/src/env.rs b/src/env.rs index d4b6ae2..355412b 100644 --- a/src/env.rs +++ b/src/env.rs @@ -98,8 +98,12 @@ impl Env { } } + pub(crate) fn dir(&self) -> &Path { + self.dir.as_ref().as_ref() + } + pub(crate) fn resolve(&self, path: impl AsRef) -> PathBuf { - self.dir.as_ref().as_ref().join(path).clean() + self.dir().join(path).clean() } } diff --git a/src/error.rs b/src/error.rs index 696f838..11ee851 100644 --- a/src/error.rs +++ b/src/error.rs @@ -5,28 +5,34 @@ use structopt::clap; #[derive(Debug, Snafu)] #[snafu(visibility(pub(crate)))] pub(crate) enum Error { + #[snafu(display("Must provide at least one announce URL"))] + AnnounceEmpty, + #[snafu(display("Failed to parse announce URL: {}", source))] + AnnounceUrlParse { source: url::ParseError }, + #[snafu(display("Failed to decode bencode: {}", source))] + BencodeDecode { source: serde_bencode::Error }, #[snafu(display("{}", source))] Clap { source: clap::Error }, - #[snafu(display("I/O error at `{}`: {}", path.display(), source))] - Filesystem { source: io::Error, path: PathBuf }, - #[snafu(display("Failed to write to standard error: {}", source))] - Stderr { source: io::Error }, - #[snafu(display("Failed to write to standard output: {}", source))] - Stdout { source: io::Error }, - #[snafu(display("Serialization failed: {}", source))] - Serialize { source: serde_bencode::Error }, + #[snafu(display("Failed to invoke command `{}`: {}", command, source,))] + CommandInvoke { command: String, source: io::Error }, + #[snafu(display("Command `{}` returned bad exit status: {}", command, status))] + CommandStatus { command: String, status: ExitStatus }, #[snafu(display("Filename was not valid unicode: {}", filename.to_string_lossy()))] FilenameDecode { filename: OsString }, #[snafu(display("Path had no file name: {}", path.display()))] FilenameExtract { path: PathBuf }, + #[snafu(display("I/O error at `{}`: {}", path.display(), source))] + Filesystem { source: io::Error, path: PathBuf }, + #[snafu(display("Failed to find opener utility, please install one of {}", tried.join(",")))] + OpenerMissing { tried: &'static [&'static str] }, + #[snafu(display("Serialization failed: {}", source))] + Serialize { source: serde_bencode::Error }, + #[snafu(display("Failed to write to standard error: {}", source))] + Stderr { source: io::Error }, + #[snafu(display("Failed to write to standard output: {}", source))] + Stdout { source: io::Error }, #[snafu(display("Failed to retrieve system time: {}", source))] SystemTime { source: SystemTimeError }, - #[snafu(display("Failed to parse announce URL: {}", source))] - AnnounceUrlParse { source: url::ParseError }, - #[snafu(display("Must provide at least one announce URL"))] - AnnounceEmpty, - #[snafu(display("Failed to decode bencode: {}", source))] - BencodeDecode { source: serde_bencode::Error }, #[snafu(display( "Feature `{}` cannot be used without passing the `--unstable` flag", feature diff --git a/src/main.rs b/src/main.rs index 79f3f60..dfb736d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -53,6 +53,8 @@ mod metainfo; mod mode; mod opt; mod path_ext; +mod platform; +mod platform_interface; mod reckoner; mod style; mod subcommand; diff --git a/src/platform.rs b/src/platform.rs new file mode 100644 index 0000000..60d186f --- /dev/null +++ b/src/platform.rs @@ -0,0 +1,39 @@ +use crate::common::*; + +pub(crate) struct Platform; + +#[cfg(target_os = "windows")] +impl PlatformInterface for Platform { + fn opener() -> Result, Error> { + let exe = if cfg!(test) { "open.bat" } else { "cmd" }; + Ok(vec![ + OsString::from(exe), + OsString::from("/C"), + OsString::from("start"), + ]) + } +} + +#[cfg(target_os = "macos")] +impl PlatformInterface for Platform { + fn opener() -> Result, Error> { + Ok(vec![OsString::from("open")]) + } +} + +#[cfg(not(any(target_os = "windows", target_os = "macos")))] +impl PlatformInterface for Platform { + fn opener() -> Result, Error> { + const OPENERS: &[&str] = &["xdg-open", "gnome-open", "kde-open"]; + + for opener in OPENERS { + if let Ok(output) = Command::new(opener).arg("--version").output() { + if output.status.success() { + return Ok(vec![OsString::from(opener)]); + } + } + } + + Err(Error::OpenerMissing { tried: OPENERS }) + } +} diff --git a/src/platform_interface.rs b/src/platform_interface.rs new file mode 100644 index 0000000..a87eb81 --- /dev/null +++ b/src/platform_interface.rs @@ -0,0 +1,35 @@ +use crate::common::*; + +pub(crate) trait PlatformInterface { + fn open(path: &Path) -> Result<(), Error> { + let mut command = Self::opener()?; + command.push(OsString::from(path)); + + let command_string = || { + command + .iter() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect::>() + .join(",") + }; + + let status = Command::new(&command[0]) + .args(&command[1..]) + .status() + .map_err(|source| Error::CommandInvoke { + source, + command: command_string(), + })?; + + if status.success() { + Ok(()) + } else { + Err(Error::CommandStatus { + command: command_string(), + status, + }) + } + } + + fn opener() -> Result, Error>; +} diff --git a/src/torrent/create.rs b/src/torrent/create.rs index db712ff..909b8b1 100644 --- a/src/torrent/create.rs +++ b/src/torrent/create.rs @@ -68,6 +68,13 @@ Note: Many BitTorrent clients do not implement the behavior described in BEP 12. help = "Do not populate `creation date` key of generated torrent with current time." )] no_creation_date: bool, + #[structopt( + name = "OPEN", + long = "open", + help = "Open `.torrent` file after creation", + long_help = "Open `.torrent` file after creation. Uses `xdg-open`, `gnome-open`, or `kde-open` on Linux; `open` on macOS; and `cmd /C start on Windows" + )] + open: bool, #[structopt( name = "OUTPUT", long = "output", @@ -178,6 +185,10 @@ impl Create { fs::write(&output, bytes).context(error::Filesystem { path: &output })?; + if self.open { + Platform::open(&output)?; + } + Ok(()) } } @@ -630,4 +641,56 @@ mod tests { ); assert_eq!(metainfo.info.mode, Mode::Multiple { files: Vec::new() }) } + + #[test] + fn open() { + let mut env = environment(&["--input", "foo", "--announce", "http://bar", "--open"]); + + let opened = env.resolve("opened.txt"); + let torrent = env.resolve("foo.torrent"); + + let expected = if cfg!(target_os = "windows") { + let script = env.resolve("open.bat"); + fs::write(&script, format!("echo %3 > {}", opened.display())).unwrap(); + format!("{} \r\n", torrent.display()) + } else { + let script = env.resolve(&Platform::opener().unwrap()[0]); + fs::write( + &script, + format!("#!/usr/bin/env sh\necho $1 > {}", opened.display()), + ) + .unwrap(); + + Command::new("chmod") + .arg("+x") + .arg(&script) + .status() + .unwrap(); + + format!("{}\n", torrent.display()) + }; + + const KEY: &str = "PATH"; + let path = env::var_os(KEY).unwrap(); + let mut split = env::split_paths(&path) + .into_iter() + .collect::>(); + split.insert(0, env.dir().to_owned()); + let new = env::join_paths(split).unwrap(); + env::set_var(KEY, new); + + fs::write(env.resolve("foo"), "").unwrap(); + env.run().unwrap(); + + let start = Instant::now(); + + while start.elapsed() < Duration::new(2, 0) { + if let Ok(text) = fs::read_to_string(&opened) { + assert_eq!(text, expected); + return; + } + } + + panic!("Failed to read `opened.txt`."); + } }