diff --git a/Cargo.lock b/Cargo.lock index 813da8e..1fad228 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,15 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + [[package]] name = "android_system_properties" version = "0.1.5" @@ -95,6 +104,7 @@ dependencies = [ "serde", "serde_repr", "tokio", + "tracing", "url", "zbus", ] @@ -296,6 +306,8 @@ dependencies = [ "i18n-embed-fl", "rust-embed", "tokio", + "tracing", + "tracing-subscriber", "zbus", ] @@ -881,6 +893,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" version = "0.2.175" @@ -925,6 +943,15 @@ version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "memchr" version = "2.7.5" @@ -973,6 +1000,15 @@ dependencies = [ "memoffset", ] +[[package]] +name = "nu-ansi-term" +version = "0.50.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a28e057d01f97e61255210fcff094d74ed0466038633e95017f5beb68e4399" +dependencies = [ + "windows-sys 0.52.0", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1188,6 +1224,23 @@ dependencies = [ "thiserror 2.0.16", ] +[[package]] +name = "regex-automata" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b9458fa0bfeeac22b5ca447c63aaf45f28439a709ccd244698632f9aa6394d6" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" + [[package]] name = "rust-embed" version = "8.7.2" @@ -1326,6 +1379,15 @@ dependencies = [ "digest", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "1.3.0" @@ -1465,6 +1527,15 @@ dependencies = [ "syn", ] +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + [[package]] name = "tinystr" version = "0.8.1" @@ -1561,6 +1632,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", ] [[package]] @@ -1638,6 +1739,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "version_check" version = "0.9.5" @@ -1833,6 +1940,15 @@ dependencies = [ "windows-link 0.2.0", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" diff --git a/Cargo.toml b/Cargo.toml index 59df2dd..7bbca52 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,20 +4,30 @@ version = "0.1.0" edition = "2024" [dependencies] -ashpd = { version = "0.12", default-features = false, features = ["tokio"] } -chrono = { version = "0.4.41", default-features = false, features = [ - "alloc", - "clock", +# App +chrono = { version = "0.4", default-features = false, features = [ + "alloc", + "clock", ] } +clap = { version = "4.5", features = ["derive"] } dirs = "6" -tokio = { version = "1.47.1", default-features = false, features = ["macros"] } -clap = { version = "4.5.46", features = ["derive"] } -zbus = { version = "5", default-features = false } +tokio = { version = "1", default-features = false, features = ["macros"] } + +# Screenshots/D-Bus comm +ashpd = { version = "0.12", default-features = false, features = [ + "tokio", + "tracing", +] } +zbus = { version = "5", default-features = false, features = ["tokio"] } + +# Logging +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } # Internationalization i18n-embed = { version = "0.16", features = [ - "fluent-system", - "desktop-requester", + "fluent-system", + "desktop-requester", ] } i18n-embed-fl = "0.10" rust-embed = "8.5" diff --git a/i18n/en/cosmic_screenshot.ftl b/i18n/en/cosmic_screenshot.ftl index c97446f..3bf7bf8 100644 --- a/i18n/en/cosmic_screenshot.ftl +++ b/i18n/en/cosmic_screenshot.ftl @@ -1,4 +1,10 @@ cosmic-screenshot = COSMIC Screenshot screenshot-saved-to-clipboard = Screenshot saved to clipboard -screenshot-saved-to = Screenshot saved to: \ No newline at end of file +screenshot-saved-to = Screenshot saved to: + +screenshot-cancelled = Screenshot canceled +screenshot-dbus-err = Error from D-Bus +screenshot-failed = Screenshot failed +screenshot-no-dir = Unable to write screenshot to directory: {$path} +screenshot-not-allowed = Screenshot not allowed: {$msg} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..705fbd8 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,126 @@ +use std::{ + error::Error as StdError, + fmt::{self, Display}, + io, + path::{Path, PathBuf}, +}; + +use ashpd::{Error as AshpdError, PortalError, desktop::ResponseError}; +use zbus::Error as ZbusError; + +use crate::fl; + +/// Error type for requesting screenshots from the XDG portal. +/// +/// The primary purpose of this type is to provide simple user facing messages. +#[derive(Debug)] +pub enum Error { + /// Screenshot errors from the portal or D-Bus + Ashpd(AshpdError), + /// Failure to post a notification + Notify(ZbusError), + /// Invalid directory path passed AND no Pictures XDG directory + MissingSaveDirectory(Option), + /// Screenshot succeeded but cannot be saved + SaveScreenshot { + error: io::Error, + context: &'static str, + }, +} + +impl StdError for Error {} + +// Log facing display messages for programmers or debugging +impl Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Ashpd(e) => e.fmt(f), + Self::Notify(e) => write!(f, "posting a notification: {e}"), + Self::MissingSaveDirectory(p) => { + let msg = p + .as_deref() + .map(|path| format!("opening `{}` or the Pictures directory", path.display())); + + write!( + f, + "{}", + msg.as_deref().unwrap_or("opening Pictures directory") + ) + } + Self::SaveScreenshot { error, context } => write!(f, "{context}: {error}"), + } + } +} + +impl Error { + /// Localized, condensed error message for end users + pub fn to_user_facing(&self) -> String { + match self { + // _ if self.unsupported() => fl!("screenshot-unsupported"), + _ if self.cancelled() => fl!("screenshot-cancelled"), + _ if self.zbus() => fl!("screenshot-dbus-err"), + Self::MissingSaveDirectory(p) => { + fl!( + "screenshot-no-dir", + path = p.as_deref().unwrap_or(Path::new("")).to_string_lossy() + ) + } + Self::Ashpd(AshpdError::Portal(PortalError::NotAllowed(msg))) => { + fl!("screenshot-not-allowed", msg = msg) + } + // Self::SaveScreenshot { .. } => "Screenshot captured but couldn't be saved".into(), + _ => fl!("screenshot-failed"), + } + } + + /// Screenshot request cancelled + pub fn cancelled(&self) -> bool { + let Self::Ashpd(e) = self else { + return false; + }; + + match e { + AshpdError::Response(e) => *e == ResponseError::Cancelled, + AshpdError::Portal(PortalError::Cancelled(_)) => true, + _ => false, + } + } + + /// Portal does not support screenshots + pub fn unsupported(&self) -> bool { + if let Self::Ashpd(e) = self { + match e { + // Requires version `x` but interface only supports version `y` + AshpdError::RequiresVersion(_, _) => true, + // Unsupported screenshot method or interface for screenshots not found + AshpdError::Portal(PortalError::ZBus(e)) => { + *e == ZbusError::Unsupported || *e == ZbusError::InterfaceNotFound + } + AshpdError::Zbus(e) => { + *e == ZbusError::Unsupported || *e == ZbusError::InterfaceNotFound + } + _ => false, + } + } else { + false + } + } + + /// D-Bus communication problem + /// + /// [zbus::Error] encapsulates many different problems, many of which are programmer errors + /// which shouldn't occur during normal operation. + pub fn zbus(&self) -> bool { + matches!( + self, + Self::Ashpd(AshpdError::Zbus(_)) + | Self::Ashpd(AshpdError::Portal(PortalError::ZBus(_))) + ) + } +} + +impl From for Error { + fn from(value: AshpdError) -> Self { + Self::Ashpd(value) + } +} diff --git a/src/main.rs b/src/main.rs index 84ed2ec..9316628 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,9 +1,15 @@ +mod error; +mod localize; + +use std::{collections::HashMap, fs, os::unix::fs::MetadataExt, path::PathBuf}; + use ashpd::desktop::screenshot::Screenshot; use clap::{ArgAction, Parser, command}; -use std::{collections::HashMap, fs, os::unix::fs::MetadataExt, path::PathBuf}; +use tracing::{debug, error, info}; +use tracing_subscriber::{EnvFilter, fmt, prelude::*}; use zbus::{Connection, proxy, zvariant::Value}; -mod localize; +use error::Error; #[derive(Parser, Default, Debug, Clone, PartialEq, Eq)] #[command(version, about, long_about = None)] @@ -54,29 +60,52 @@ trait Notifications { ) -> zbus::Result; } -//TODO: better error handling -#[tokio::main(flavor = "current_thread")] -async fn main() { - crate::localize::localize(); +// Send a notification for the screenshot app. +#[tracing::instrument(name = "Posting notification")] +async fn send_notify(summary: &str, body: &str) -> Result<(), Error> { + let connection = Connection::session().await.map_err(Error::Notify)?; - let args = Args::parse(); - let picture_dir = (!args.interactive).then(|| { - args.save_dir - .filter(|dir| dir.is_dir()) - .unwrap_or_else(|| dirs::picture_dir().expect("failed to locate picture directory")) - }); + let proxy = NotificationsProxy::new(&connection) + .await + .map_err(Error::Notify)?; + proxy + .notify( + &fl!("cosmic-screenshot"), + 0, + "com.system76.CosmicScreenshot", + summary, + body, + &[], + HashMap::from([("transient", &Value::Bool(true))]), + 5000, + ) + .await + .map_err(Error::Notify) + .map(|_| ()) +} + +#[tracing::instrument(name = "Requesting screenshot from D-Bus")] +async fn request_screenshot(args: Args) -> Result { + let picture_dir = (!args.interactive) + .then(|| { + args.save_dir + .clone() + .filter(|dir| dir.is_dir()) + .or_else(dirs::picture_dir) + .ok_or_else(|| Error::MissingSaveDirectory(args.save_dir)) + }) + .transpose()?; let response = Screenshot::request() .interactive(args.interactive) .modal(args.modal) .send() - .await - .expect("failed to send screenshot request") - .response() - .expect("failed to receive screenshot response"); + .await? + .response()?; let uri = response.uri(); - let path = match uri.scheme() { + debug!("Screenshot request URI: {uri}"); + match uri.scheme() { "file" => { let response_path = uri .to_file_path() @@ -86,55 +115,79 @@ async fn main() { let filename = format!("Screenshot_{}.png", date.format("%Y-%m-%d_%H-%M-%S")); let path = picture_dir.join(filename); if fs::metadata(&picture_dir) - .expect("Failed to get medatata on filesystem for screenshot destination") + .map_err(|error| Error::SaveScreenshot { + error, + context: "metadata for screenshot destination", + })? .dev() != fs::metadata(&response_path) - .expect("Failed to get metadata on filesystem for temporary path") + .map_err(|error| Error::SaveScreenshot { + error, + context: "metadata for temporary path", + })? .dev() { // copy file instead - fs::copy(&response_path, &path).expect("failed to move screenshot"); - fs::remove_file(&response_path).expect("failed to remove temporary screenshot"); + fs::copy(&response_path, &path).map_err(|error| Error::SaveScreenshot { + error, + context: "copying screenshot", + })?; + fs::remove_file(&response_path).map_err(|error| Error::SaveScreenshot { + error, + context: "removing temporary screenshot", + })?; } else { - fs::rename(&response_path, &path).expect("failed to move screenshot"); + fs::rename(&response_path, &path).map_err(|error| Error::SaveScreenshot { + error, + context: "moving screenshot", + })?; } - - path.to_string_lossy().to_string() + Ok(path.to_string_lossy().to_string()) } else { - response_path.to_string_lossy().to_string() + Ok(uri.path().to_string()) } } - "clipboard" => String::new(), - scheme => panic!("unsupported scheme '{}'", scheme), - }; + "clipboard" => Ok(String::new()), + scheme => { + error!("Unsupported URL scheme: {scheme}"); + Err(Error::Ashpd(ashpd::Error::Zbus(zbus::Error::Unsupported))) + } + } +} + +#[tokio::main(flavor = "current_thread")] +async fn main() { + // Init tracing but don't panic if it fails + let _ = tracing_subscriber::registry() + .with(fmt::layer()) + .with(EnvFilter::from_default_env()) + .try_init(); + crate::localize::localize(); - println!("{path}"); + let args = Args::parse(); + let notify = args.notify; - if args.notify { - let connection = Connection::session() - .await - .expect("failed to connect to session bus"); + let (summary, body) = match request_screenshot(args).await { + Ok(path) => { + info!("Screenshot saved to {path}"); + if path.is_empty() { + (fl!("screenshot-saved-to-clipboard"), "".into()) + } else { + (fl!("screenshot-saved-to"), path) + } + } + Err(e) => { + if !e.cancelled() { + error!("Screenshot failed with {e}"); + (fl!("screenshot-failed"), e.to_user_facing()) + } else { + info!("Screenshot cancelled"); + (fl!("screenshot-cancelled"), "".into()) + } + } + }; - let message = if path.is_empty() { - fl!("screenshot-saved-to-clipboard") - } else { - fl!("screenshot-saved-to") - }; - let proxy = NotificationsProxy::new(&connection) - .await - .expect("failed to create proxy"); - _ = proxy - .notify( - &fl!("cosmic-screenshot"), - 0, - "com.system76.CosmicScreenshot", - &message, - &path, - &[], - HashMap::from([("transient", &Value::Bool(true))]), - 5000, - ) - .await - .expect("failed to send notification"); + if notify && let Err(e) = send_notify(&summary, &body).await { + error!("Failed to post notification on completion: {e}"); } }