use crate::{ backend::{Cancel, Options, Request, authorize}, consent::Prompt, }; use std::{ collections::HashMap, io::Read, os::unix::{fs::PermissionsExt, net::UnixStream}, path::{Path, PathBuf}, process::Stdio, sync::{Arc, atomic::Ordering}, time::Duration, }; use tokio::{io::AsyncWriteExt, sync::Mutex}; use zbus::{ Connection, message::Header, zvariant::{OwnedObjectPath, Value}, }; const PATH: &str = "/org/freedesktop/portal/desktop"; const MAX_IMAGE_BYTES: u64 = 128 * 1024 * 1024; type Reply = (u32, Options); #[derive(Clone, Default)] pub(crate) struct Requests(Arc)>>>); impl Requests { pub(crate) async fn revoke_stale(&self, owner: Option<&str>) { for (request_owner, cancel) in self.0.lock().await.values() { if Some(request_owner.as_str()) != owner { cancel.stop(); } } } pub(crate) async fn begin( &self, connection: &Connection, header: &Header<'_>, handle: &OwnedObjectPath, ) -> zbus::fdo::Result> { let owner = authorize(connection, header).await?; if !handle.as_str().starts_with(&format!("{PATH}/request/")) { return Err(zbus::fdo::Error::InvalidArgs("Invalid request path".into())); } let mut pending = self.0.lock().await; if pending.len() >= 8 || pending.contains_key(handle.as_str()) { return Err(zbus::fdo::Error::LimitsExceeded( "Request limit reached".into(), )); } let cancel = Arc::new(Cancel::default()); if !connection .object_server() .at( handle, Request { owner: owner.clone(), cancel: cancel.clone(), }, ) .await? { return Err(zbus::fdo::Error::InvalidArgs( "Request already exists".into(), )); } pending.insert(handle.to_string(), (owner, cancel.clone())); Ok(cancel) } pub(crate) async fn end(&self, connection: &Connection, handle: &OwnedObjectPath) { self.0.lock().await.remove(handle.as_str()); let _ = connection .object_server() .remove::(handle) .await; } } #[derive(Clone)] pub(crate) struct Screenshot(pub(crate) Requests); #[zbus::interface(name = "org.freedesktop.impl.portal.Screenshot")] impl Screenshot { #[zbus(property, name = "version")] fn version(&self) -> u32 { 3 } #[zbus(property)] fn available_targets(&self) -> u32 { 1 | 4 } async fn screenshot( &self, handle: OwnedObjectPath, app_id: String, parent_window: String, options: Options, #[zbus(connection)] connection: &Connection, #[zbus(header)] header: Header<'_>, ) -> zbus::fdo::Result<(u32, Options)> { let cancel = self.0.begin(connection, &header, &handle).await?; let result = tokio::select! { biased; _ = cancel.wait() => (1, Options::new()), result = screenshot(&app_id, &parent_window, &options) => reply(result), }; self.0.end(connection, &handle).await; Ok(result) } async fn pick_color( &self, handle: OwnedObjectPath, app_id: String, parent_window: String, _options: Options, #[zbus(connection)] connection: &Connection, #[zbus(header)] header: Header<'_>, ) -> zbus::fdo::Result<(u32, Options)> { let cancel = self.0.begin(connection, &header, &handle).await?; let result = tokio::select! { biased; _ = cancel.wait() => (1, Options::new()), result = pick_color(&app_id, &parent_window) => reply(result), }; self.0.end(connection, &handle).await; Ok(result) } } #[derive(Clone)] pub(crate) struct Wallpaper(pub(crate) Requests); #[zbus::interface(name = "org.freedesktop.impl.portal.Wallpaper")] impl Wallpaper { #[zbus(name = "SetWallpaperURI")] async fn set_wallpaper_uri( &self, handle: OwnedObjectPath, app_id: String, parent_window: String, uri: String, options: Options, #[zbus(connection)] connection: &Connection, #[zbus(header)] header: Header<'_>, ) -> zbus::fdo::Result { let cancel = self.0.begin(connection, &header, &handle).await?; let result = tokio::select! { biased; _ = cancel.wait() => 1, result = wallpaper(&app_id, &parent_window, &uri, &options, cancel.clone()) => reply(result).0, }; self.0.end(connection, &handle).await; Ok(result) } } pub(crate) fn reply(result: Result, String>) -> Reply { match result { Ok(Some(values)) => (0, values), Ok(None) => (1, Options::new()), Err(error) => { eprintln!("ferese portal: {error}"); (2, Options::new()) } } } pub(crate) async fn consent( app: &str, parent: &str, title: &str, description: &str, accept: &str, image: Option, ) -> Result { if app.len() > 512 { return Err("Application ID is too long".into()); } let prompt = Prompt { parent: parent.into(), title: title.into(), description: if app.is_empty() { description.into() } else { format!("{app}\n{description}") }, accept: accept.into(), image, shortcuts: Vec::new(), }; let mut child = crate::backend::child_command()? .arg("--consent") .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::inherit()) .kill_on_drop(true) .spawn() .map_err(|error| error.to_string())?; let pid = child.id().ok_or("Missing consent process ID")?; let mut input = child.stdin.take().unwrap(); input .write_all(&serde_json::to_vec(&prompt).map_err(|error| error.to_string())?) .await .map_err(|error| error.to_string())?; drop(input); let output = tokio::time::timeout(Duration::from_secs(300), child.wait_with_output()) .await .map_err(|_| "Consent timed out")? .map_err(|error| error.to_string())?; if output.status.success() && output.stdout == b"true\n" { wait_for_surface_removal(pid).await?; Ok(true) } else { Ok(false) } } pub(crate) async fn ipc( command: &'static str, args: serde_json::Value, ) -> Result { tokio::task::spawn_blocking(move || { let path = std::env::var_os("XDG_RUNTIME_DIR") .map(PathBuf::from) .ok_or("Missing runtime directory")? .join("ferese/control.sock"); let mut stream = UnixStream::connect(path).map_err(|error| error.to_string())?; stream .set_read_timeout(Some(Duration::from_secs(15))) .map_err(|error| error.to_string())?; stream .set_write_timeout(Some(Duration::from_secs(2))) .map_err(|error| error.to_string())?; let request = ferese_ipc::Request { version: ferese_ipc::VERSION, id: 1, kind: "command".into(), command: command.into(), args, }; ferese_ipc::write_frame(&mut stream, &request).map_err(|error| error.to_string())?; let response: ferese_ipc::Response = ferese_ipc::read_frame(&mut stream).map_err(|error| error.to_string())?; if let Some(error) = response.error { return Err(error.message); } response.result.ok_or("Missing IPC result".into()) }) .await .map_err(|error| error.to_string())? } async fn select_geometry(point: bool, windows: Option) -> Result, String> { let mut command = crate::backend::child_command_for("slurp")?; if point { command.arg("-p"); } if windows.is_some() { command.arg("-r"); } command.stdin(if windows.is_some() { Stdio::piped() } else { Stdio::null() }); let mut child = command .stdout(Stdio::piped()) .stderr(Stdio::inherit()) .kill_on_drop(true) .spawn() .map_err(|error| error.to_string())?; let pid = child.id().ok_or("Missing selection process ID")?; if let Some(windows) = windows { let mut input = child.stdin.take().unwrap(); input .write_all(windows.as_bytes()) .await .map_err(|error| error.to_string())?; drop(input); } let output = tokio::time::timeout(Duration::from_secs(300), child.wait_with_output()) .await .map_err(|_| "Selection timed out")? .map_err(|error| error.to_string())?; if !output.status.success() { return Ok(None); } let geometry = String::from_utf8(output.stdout) .map_err(|error| error.to_string())? .trim() .to_owned(); if geometry.len() > 128 || geometry.is_empty() { return Err("Invalid selection".into()); } wait_for_surface_removal(pid).await?; Ok(Some(geometry)) } pub(crate) async fn wait_for_surface_removal(pid: u32) -> Result<(), String> { for _ in 0..100 { if ipc("has-client-surfaces", serde_json::json!({"pid": pid})).await? == false { return Ok(()); } tokio::time::sleep(Duration::from_millis(10)).await; } Err("Screenshot helper is still visible".into()) } async fn capture(geometry: Option) -> Result, String> { let result = ipc("screenshot", serde_json::json!({"geometry": geometry})).await?; let path = result["path"] .as_str() .ok_or("Missing screenshot path")? .to_owned(); tokio::task::spawn_blocking(move || { let file = std::fs::File::open(&path).map_err(|error| error.to_string())?; let _ = std::fs::remove_file(path); let mut bytes = Vec::new(); file.take(MAX_IMAGE_BYTES + 1) .read_to_end(&mut bytes) .map_err(|error| error.to_string())?; if bytes.len() as u64 > MAX_IMAGE_BYTES { return Err("Screenshot is too large".into()); } Ok(bytes) }) .await .map_err(|error| error.to_string())? } async fn screenshot(app: &str, parent: &str, options: &Options) -> Result, String> { let target = options .get("target") .map(u32::try_from) .transpose() .map_err(|_| "Invalid screenshot target")? .unwrap_or( if options .get("interactive") .map(bool::try_from) .transpose() .map_err(|_| "Invalid interactive hint")? .unwrap_or(false) { 4 } else { 1 }, ); if !matches!(target, 1 | 4) { return Err("Unsupported screenshot target".into()); } if !consent( app, parent, "Take a screenshot?", "Allow this app to capture the selected screen content.", "Continue", None, ) .await? { return Ok(None); } let geometry = match target { 1 => None, 4 => { let Some(geometry) = select_geometry(false, None).await? else { return Ok(None); }; Some(geometry) } _ => unreachable!(), }; let bytes = capture(geometry).await?; let directory = private_directory("screenshots")?; let mut file = tempfile::Builder::new() .prefix("screenshot-") .suffix(".png") .tempfile_in(directory) .map_err(|error| error.to_string())?; std::io::Write::write_all(&mut file, &bytes).map_err(|error| error.to_string())?; let (_, path) = file.keep().map_err(|error| error.to_string())?; Ok(Some(HashMap::from([( "uri".into(), Value::from(file_uri(&path)?) .try_to_owned() .map_err(|error| error.to_string())?, )]))) } async fn pick_color(app: &str, parent: &str) -> Result, String> { if !consent( app, parent, "Pick a screen color?", "Choose a pixel to share its color with this app.", "Choose color", None, ) .await? { return Ok(None); } let Some(geometry) = select_geometry(true, None).await? else { return Ok(None); }; let bytes = capture(Some(geometry)).await?; let color = png_color(&bytes)?; Ok(Some(HashMap::from([( "color".into(), Value::from(color) .try_to_owned() .map_err(|error| error.to_string())?, )]))) } fn png_color(bytes: &[u8]) -> Result<(f64, f64, f64), String> { let mut decoder = png::Decoder::new(std::io::Cursor::new(bytes)); decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16); let mut reader = decoder.read_info().map_err(|error| error.to_string())?; let size = reader .output_buffer_size() .filter(|size| *size <= 16 * 1024) .ok_or("Unexpected pixel dimensions")?; let mut pixels = vec![0; size]; let info = reader .next_frame(&mut pixels) .map_err(|error| error.to_string())?; if info.width == 0 || info.height == 0 { return Err("Empty image".into()); } match info.color_type { png::ColorType::Rgb | png::ColorType::Rgba => Ok(( pixels[0] as f64 / 255., pixels[1] as f64 / 255., pixels[2] as f64 / 255., )), _ => Err("Unexpected screenshot pixel format".into()), } } fn private_directory(name: &str) -> Result { let directory = dirs::data_local_dir() .ok_or("Missing data directory")? .join("ferese/portal") .join(name); std::fs::create_dir_all(&directory).map_err(|error| error.to_string())?; std::fs::set_permissions(&directory, std::fs::Permissions::from_mode(0o700)) .map_err(|error| error.to_string())?; Ok(directory) } fn file_uri(path: &Path) -> Result { url::Url::from_file_path(path) .map(String::from) .map_err(|_| "Invalid file path".into()) } fn local_path(uri: &str) -> Result { url::Url::parse(uri) .map_err(|error| error.to_string())? .to_file_path() .map_err(|_| "Wallpaper must be a local file URI".into()) } async fn wallpaper( app: &str, parent: &str, uri: &str, options: &Options, cancel: Arc, ) -> Result, String> { let target = options .get("set-on") .map(<&str>::try_from) .transpose() .map_err(|_| "Invalid wallpaper target")? .unwrap_or("background"); if !matches!(target, "background" | "lockscreen" | "both") { return Err("Unsupported wallpaper target".into()); } let input = local_path(uri)?; let preparing = cancel.clone(); let file = tokio::task::spawn_blocking(move || prepare_wallpaper(&input, &preparing)) .await .map_err(|error| error.to_string())??; if !consent( app, parent, "Change your wallpaper?", "Use this picture as your wallpaper.", "Set wallpaper", Some(file.path().to_path_buf()), ) .await? { return Ok(None); } if cancel.stopped.load(Ordering::SeqCst) { return Ok(None); } let (_, path) = file.keep().map_err(|error| error.to_string())?; if let Err(error) = update_wallpaper(&path, target) { let _ = std::fs::remove_file(path); return Err(error); } Ok(Some(Options::new())) } fn prepare_wallpaper(input: &Path, cancel: &Cancel) -> Result { use std::os::unix::fs::OpenOptionsExt; let input = std::fs::OpenOptions::new() .read(true) .custom_flags(libc::O_NONBLOCK) .open(input) .map_err(|error| error.to_string())?; if !input .metadata() .map_err(|error| error.to_string())? .is_file() { return Err("Wallpaper must be a regular file".into()); } let directory = private_directory("wallpapers")?; let mut file = tempfile::Builder::new() .prefix("wallpaper-") .tempfile_in(directory) .map_err(|error| error.to_string())?; let mut input = input.take(MAX_IMAGE_BYTES + 1); let mut size = 0u64; let mut buffer = [0; 64 * 1024]; loop { if cancel.stopped.load(Ordering::SeqCst) { return Err("Wallpaper request cancelled".into()); } let bytes = input.read(&mut buffer).map_err(|error| error.to_string())?; if bytes == 0 { break; } size += bytes as u64; if size > MAX_IMAGE_BYTES { return Err("Wallpaper is too large".into()); } std::io::Write::write_all(&mut file, &buffer[..bytes]) .map_err(|error| error.to_string())?; } if size == 0 { return Err("Wallpaper is empty".into()); } let reader = image::ImageReader::open(file.path()) .map_err(|error| error.to_string())? .with_guessed_format() .map_err(|error| error.to_string())?; let (width, height) = reader .into_dimensions() .map_err(|error| error.to_string())?; if width == 0 || height == 0 || width > 16384 || height > 16384 || u64::from(width) * u64::from(height) > MAX_IMAGE_BYTES / 4 { return Err("Wallpaper dimensions exceed the image limit".into()); } let mut reader = image::ImageReader::open(file.path()) .map_err(|error| error.to_string())? .with_guessed_format() .map_err(|error| error.to_string())?; let mut limits = image::Limits::default(); limits.max_alloc = Some(MAX_IMAGE_BYTES); reader.limits(limits); reader.decode().map_err(|error| error.to_string())?; Ok(file) } fn update_wallpaper(image: &Path, target: &str) -> Result<(), String> { let _transaction = crate::backend::CONFIG_TRANSACTION .lock() .map_err(|_| "Configuration transaction failed")?; let path = ferese_config::config_path().ok_or("Missing config path")?; edit_wallpaper(&path, image, target) } fn edit_wallpaper(path: &Path, image: &Path, target: &str) -> Result<(), String> { let source = match std::fs::read_to_string(&path) { Ok(source) => source, Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(), Err(error) => return Err(error.to_string()), }; let mut document = ferese_config::Document::parse(&source).map_err(|error| error.to_string())?; if target == "background" && document.get("theme.background.lock_path").is_none() { let old = document .get("theme.background.path") .and_then(serde_json::Value::as_str) .unwrap_or(ferese_config::default_wallpaper()) .to_owned(); document .set("theme.background.lock_path", old.into()) .map_err(|error| error.to_string())?; } if target != "lockscreen" { document .set( "theme.background.path", image.to_string_lossy().into_owned().into(), ) .map_err(|error| error.to_string())?; } if target != "background" { document .set( "theme.background.lock_path", image.to_string_lossy().into_owned().into(), ) .map_err(|error| error.to_string())?; } let parent = path.parent().ok_or("Invalid config path")?; std::fs::create_dir_all(parent).map_err(|error| error.to_string())?; let mut temp = tempfile::NamedTempFile::new_in(parent).map_err(|error| error.to_string())?; std::io::Write::write_all(&mut temp, document.to_string().as_bytes()) .map_err(|error| error.to_string())?; temp.as_file() .sync_all() .map_err(|error| error.to_string())?; if std::fs::read_to_string(&path).unwrap_or_default() != source { return Err("Configuration changed; please try again".into()); } temp.persist(path).map_err(|error| error.to_string())?; Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn local_uris_preserve_special_characters_and_reject_network_paths() { let path = Path::new("/tmp/wallpaper #1_日本.png"); assert_eq!(local_path(&file_uri(path).unwrap()).unwrap(), path); for uri in [ "https://example.com/a.png", "file://example.com/a.png", "not a URI", ] { assert!(local_path(uri).is_err()); } } #[test] fn pixel_color_is_normalized_rgb() { let mut bytes = Vec::new(); let mut encoder = png::Encoder::new(&mut bytes, 1, 1); encoder.set_color(png::ColorType::Rgba); encoder.set_depth(png::BitDepth::Eight); encoder .write_header() .unwrap() .write_image_data(&[255, 128, 0, 255]) .unwrap(); assert_eq!(png_color(&bytes).unwrap(), (1., 128. / 255., 0.)); } #[test] fn wallpaper_targets_preserve_independent_paths_and_other_settings() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("config.kdl"); std::fs::write(&path, "// Keep my settings\ntheme { background { path \"/old.png\"; }; }; commands { terminal \"foot\"; }\n").unwrap(); edit_wallpaper(&path, Path::new("/desktop.png"), "background").unwrap(); let document = ferese_config::Document::parse(&std::fs::read_to_string(&path).unwrap()).unwrap(); assert_eq!( document.get("theme.background.path").unwrap(), "/desktop.png" ); assert_eq!( document.get("theme.background.lock_path").unwrap(), "/old.png" ); assert!( std::fs::read_to_string(&path) .unwrap() .contains("Keep my settings") ); edit_wallpaper(&path, Path::new("/lock.png"), "lockscreen").unwrap(); let document = ferese_config::Document::parse(&std::fs::read_to_string(&path).unwrap()).unwrap(); assert_eq!( document.get("theme.background.path").unwrap(), "/desktop.png" ); assert_eq!( document.get("theme.background.lock_path").unwrap(), "/lock.png" ); edit_wallpaper(&path, Path::new("/both.png"), "both").unwrap(); let document = ferese_config::Document::parse(&std::fs::read_to_string(&path).unwrap()).unwrap(); assert_eq!(document.get("theme.background.path").unwrap(), "/both.png"); assert_eq!( document.get("theme.background.lock_path").unwrap(), "/both.png" ); assert_eq!( document.get("commands.terminal").unwrap(), &serde_json::json!(["foot"]) ); } #[test] fn wallpaper_rejects_nonregular_files_and_cancellation_before_copy() { let dir = tempfile::tempdir().unwrap(); assert!(prepare_wallpaper(dir.path(), &Cancel::default()).is_err()); let path = dir.path().join("image"); std::fs::write(&path, [0u8; 32]).unwrap(); let cancel = Cancel::default(); cancel.stop(); assert!( prepare_wallpaper(&path, &cancel) .unwrap_err() .contains("cancelled") ); } }