From 08d442aee26c25f104a35d7149efc45313eaaad9 Mon Sep 17 00:00:00 2001 From: Frederic Laing Date: Fri, 16 Jan 2026 21:56:34 +0100 Subject: [PATCH 1/3] feat: paste images, videos, and text from clipboard --- i18n/en/cosmic_files.ftl | 3 + src/app.rs | 96 ++++++++++++++++++++++- src/clipboard.rs | 159 +++++++++++++++++++++++++++++++++++++++ src/operation/mod.rs | 2 +- 4 files changed, 256 insertions(+), 4 deletions(-) diff --git a/i18n/en/cosmic_files.ftl b/i18n/en/cosmic_files.ftl index 96132a2..fe080cd 100644 --- a/i18n/en/cosmic_files.ftl +++ b/i18n/en/cosmic_files.ftl @@ -200,6 +200,9 @@ compressed = Compressed {$items} {$items -> *[other] items } from "{$from}" to "{$to}" copy_noun = Copy +pasted-image = Pasted Image +pasted-text = Pasted Text +pasted-video = Pasted Video creating = Creating "{$name}" in "{$parent}" created = Created "{$name}" in "{$parent}" copying = Copying {$items} {$items -> diff --git a/src/app.rs b/src/app.rs index 160350d..9a48671 100644 --- a/src/app.rs +++ b/src/app.rs @@ -72,7 +72,10 @@ use wayland_client::{Proxy, protocol::wl_output::WlOutput}; use crate::{ FxOrderMap, - clipboard::{ClipboardCopy, ClipboardKind, ClipboardPaste}, + clipboard::{ + ClipboardCopy, ClipboardKind, ClipboardPaste, ClipboardPasteImage, ClipboardPasteText, + ClipboardPasteVideo, + }, config::{ AppTheme, Config, DesktopConfig, Favorite, IconSizes, State, TIME_CONFIG_ID, TabConfig, TimeConfig, TypeToSearch, @@ -87,7 +90,7 @@ use crate::{ mounter::{MOUNTERS, MounterAuth, MounterItem, MounterItems, MounterKey, MounterMessage}, operation::{ Controller, Operation, OperationError, OperationErrorType, OperationSelection, - ReplaceResult, + ReplaceResult, copy_unique_path, }, spawn_detached::spawn_detached, tab::{ @@ -385,6 +388,12 @@ pub enum Message { Overlap(window::Id, OverlapNotifyEvent), Paste(Option), PasteContents(PathBuf, ClipboardPaste), + PasteImage(PathBuf), + PasteImageContents(PathBuf, ClipboardPasteImage), + PasteText(PathBuf), + PasteTextContents(PathBuf, ClipboardPasteText), + PasteVideo(PathBuf), + PasteVideoContents(PathBuf, ClipboardPasteVideo), PendingCancel(u64), PendingCancelAll, PendingComplete(u64, OperationSelection), @@ -3488,7 +3497,8 @@ impl Application for App { Some(contents) => { cosmic::action::app(Message::PasteContents(to.clone(), contents)) } - None => cosmic::action::none(), + // No file data in clipboard, try image data + None => cosmic::action::app(Message::PasteImage(to.clone())), } }); } @@ -3509,6 +3519,86 @@ impl Application for App { }; } } + Message::PasteImage(to) => { + return clipboard::read_data::().map(move |contents_opt| { + match contents_opt { + Some(contents) => { + cosmic::action::app(Message::PasteImageContents(to.clone(), contents)) + } + // No image data in clipboard, try video data + None => cosmic::action::app(Message::PasteVideo(to.clone())), + } + }); + } + Message::PasteImageContents(to, contents) => { + // Generate unique filename for the pasted image + let base_name = format!("{}.{}", fl!("pasted-image"), contents.extension()); + let base_path = to.join(&base_name); + let final_path = copy_unique_path(&base_path, &to); + + // Write image data to file + match fs::write(&final_path, &contents.data) { + Ok(_) => { + log::info!("Pasted image saved to {:?}", final_path); + } + Err(err) => { + log::error!("Failed to save pasted image: {}", err); + } + } + } + Message::PasteVideo(to) => { + return clipboard::read_data::().map(move |contents_opt| { + match contents_opt { + Some(contents) => { + cosmic::action::app(Message::PasteVideoContents(to.clone(), contents)) + } + // No video data in clipboard, try text data + None => cosmic::action::app(Message::PasteText(to.clone())), + } + }); + } + Message::PasteVideoContents(to, contents) => { + // Generate unique filename for the pasted video + let base_name = format!("{}.{}", fl!("pasted-video"), contents.extension()); + let base_path = to.join(&base_name); + let final_path = copy_unique_path(&base_path, &to); + + // Write video data to file + match fs::write(&final_path, &contents.data) { + Ok(_) => { + log::info!("Pasted video saved to {:?}", final_path); + } + Err(err) => { + log::error!("Failed to save pasted video: {}", err); + } + } + } + Message::PasteText(to) => { + return clipboard::read_data::().map(move |contents_opt| { + match contents_opt { + Some(contents) => { + cosmic::action::app(Message::PasteTextContents(to.clone(), contents)) + } + None => cosmic::action::none(), + } + }); + } + Message::PasteTextContents(to, contents) => { + // Generate unique filename for the pasted text + let base_name = format!("{}.txt", fl!("pasted-text")); + let base_path = to.join(&base_name); + let final_path = copy_unique_path(&base_path, &to); + + // Write text data to file + match fs::write(&final_path, &contents.data) { + Ok(_) => { + log::info!("Pasted text saved to {:?}", final_path); + } + Err(err) => { + log::error!("Failed to save pasted text: {}", err); + } + } + } Message::PendingCancel(id) => { if let Some((_, controller)) = self.pending_operations.get(&id) { controller.cancel(); diff --git a/src/clipboard.rs b/src/clipboard.rs index 0c17a09..c3e0bc8 100644 --- a/src/clipboard.rs +++ b/src/clipboard.rs @@ -162,3 +162,162 @@ impl TryFrom<(Vec, String)> for ClipboardPaste { Ok(Self { kind, paths }) } } + +/// Image data from clipboard for pasting as a new file. +#[derive(Clone, Debug)] +pub struct ClipboardPasteImage { + pub data: Vec, + pub mime_type: String, +} + +impl AllowedMimeTypes for ClipboardPasteImage { + fn allowed() -> Cow<'static, [String]> { + Cow::from(vec![ + "image/png".to_string(), + "image/jpeg".to_string(), + "image/gif".to_string(), + "image/bmp".to_string(), + "image/webp".to_string(), + "image/tiff".to_string(), + "image/x-tiff".to_string(), + "image/svg+xml".to_string(), + "image/x-icon".to_string(), + "image/vnd.microsoft.icon".to_string(), + "image/x-bmp".to_string(), + "image/x-ms-bmp".to_string(), + "image/pjpeg".to_string(), + "image/x-png".to_string(), + "image/avif".to_string(), + "image/heic".to_string(), + "image/heif".to_string(), + "image/jxl".to_string(), + ]) + } +} + +impl TryFrom<(Vec, String)> for ClipboardPasteImage { + type Error = Box; + fn try_from(value: (Vec, String)) -> Result { + let (data, mime) = value; + if data.is_empty() { + return Err("Empty image data".into()); + } + Ok(Self { + data, + mime_type: mime, + }) + } +} + +impl ClipboardPasteImage { + /// Get the file extension for the image based on MIME type. + pub fn extension(&self) -> &'static str { + match self.mime_type.as_str() { + "image/png" | "image/x-png" => "png", + "image/jpeg" | "image/pjpeg" => "jpg", + "image/gif" => "gif", + "image/bmp" | "image/x-bmp" | "image/x-ms-bmp" => "bmp", + "image/webp" => "webp", + "image/tiff" | "image/x-tiff" => "tiff", + "image/svg+xml" => "svg", + "image/x-icon" | "image/vnd.microsoft.icon" => "ico", + "image/avif" => "avif", + "image/heic" => "heic", + "image/heif" => "heif", + "image/jxl" => "jxl", + _ => "png", // Default to png + } + } +} + +/// Video data from clipboard for pasting as a new file. +#[derive(Clone, Debug)] +pub struct ClipboardPasteVideo { + pub data: Vec, + pub mime_type: String, +} + +impl AllowedMimeTypes for ClipboardPasteVideo { + fn allowed() -> Cow<'static, [String]> { + Cow::from(vec![ + "video/mp4".to_string(), + "video/webm".to_string(), + "video/ogg".to_string(), + "video/mpeg".to_string(), + "video/quicktime".to_string(), + "video/x-msvideo".to_string(), + "video/x-matroska".to_string(), + "video/x-flv".to_string(), + "video/3gpp".to_string(), + "video/3gpp2".to_string(), + "video/x-ms-wmv".to_string(), + "video/avi".to_string(), + ]) + } +} + +impl TryFrom<(Vec, String)> for ClipboardPasteVideo { + type Error = Box; + fn try_from(value: (Vec, String)) -> Result { + let (data, mime) = value; + if data.is_empty() { + return Err("Empty video data".into()); + } + Ok(Self { + data, + mime_type: mime, + }) + } +} + +impl ClipboardPasteVideo { + /// Get the file extension for the video based on MIME type. + pub fn extension(&self) -> &'static str { + match self.mime_type.as_str() { + "video/mp4" => "mp4", + "video/webm" => "webm", + "video/ogg" => "ogv", + "video/mpeg" => "mpeg", + "video/quicktime" => "mov", + "video/x-msvideo" | "video/avi" => "avi", + "video/x-matroska" => "mkv", + "video/x-flv" => "flv", + "video/3gpp" => "3gp", + "video/3gpp2" => "3g2", + "video/x-ms-wmv" => "wmv", + _ => "mp4", // Default to mp4 + } + } +} + +/// Text data from clipboard for pasting as a new text file. +#[derive(Clone, Debug)] +pub struct ClipboardPasteText { + pub data: String, +} + +impl AllowedMimeTypes for ClipboardPasteText { + fn allowed() -> Cow<'static, [String]> { + Cow::from(vec![ + "text/plain".to_string(), + "text/plain;charset=utf-8".to_string(), + "UTF8_STRING".to_string(), + "STRING".to_string(), + "TEXT".to_string(), + ]) + } +} + +impl TryFrom<(Vec, String)> for ClipboardPasteText { + type Error = Box; + fn try_from(value: (Vec, String)) -> Result { + let (data, _mime) = value; + if data.is_empty() { + return Err("Empty text data".into()); + } + let text = str::from_utf8(&data)?; + Ok(Self { + data: text.to_string(), + }) + } +} diff --git a/src/operation/mod.rs b/src/operation/mod.rs index d17ef5d..0669624 100644 --- a/src/operation/mod.rs +++ b/src/operation/mod.rs @@ -221,7 +221,7 @@ pub async fn sync_to_disk( .await; } -fn copy_unique_path(from: &Path, to: &Path) -> PathBuf { +pub fn copy_unique_path(from: &Path, to: &Path) -> PathBuf { // List of compound extensions to check const COMPOUND_EXTENSIONS: &[&str] = &[ ".tar.gz", From b8ac39ade5cfed1f8a6aacca6f315aa5e47a036c Mon Sep 17 00:00:00 2001 From: Frederic Laing Date: Fri, 16 Jan 2026 22:58:47 +0100 Subject: [PATCH 2/3] Return None for unknown MIME types and ignore paste with warning --- src/app.rs | 20 +++++++++++++++-- src/clipboard.rs | 56 +++++++++++++++++++++++++----------------------- 2 files changed, 47 insertions(+), 29 deletions(-) diff --git a/src/app.rs b/src/app.rs index 9a48671..b1049db 100644 --- a/src/app.rs +++ b/src/app.rs @@ -3531,8 +3531,16 @@ impl Application for App { }); } Message::PasteImageContents(to, contents) => { + let Some(extension) = contents.extension() else { + log::warn!( + "Ignoring paste: unknown image MIME type {:?}", + contents.mime_type + ); + return Task::none(); + }; + // Generate unique filename for the pasted image - let base_name = format!("{}.{}", fl!("pasted-image"), contents.extension()); + let base_name = format!("{}.{}", fl!("pasted-image"), extension); let base_path = to.join(&base_name); let final_path = copy_unique_path(&base_path, &to); @@ -3558,8 +3566,16 @@ impl Application for App { }); } Message::PasteVideoContents(to, contents) => { + let Some(extension) = contents.extension() else { + log::warn!( + "Ignoring paste: unknown video MIME type {:?}", + contents.mime_type + ); + return Task::none(); + }; + // Generate unique filename for the pasted video - let base_name = format!("{}.{}", fl!("pasted-video"), contents.extension()); + let base_name = format!("{}.{}", fl!("pasted-video"), extension); let base_path = to.join(&base_name); let final_path = copy_unique_path(&base_path, &to); diff --git a/src/clipboard.rs b/src/clipboard.rs index c3e0bc8..fd26a0e 100644 --- a/src/clipboard.rs +++ b/src/clipboard.rs @@ -211,21 +211,22 @@ impl TryFrom<(Vec, String)> for ClipboardPasteImage { impl ClipboardPasteImage { /// Get the file extension for the image based on MIME type. - pub fn extension(&self) -> &'static str { + /// Returns None if the MIME type is not recognized. + pub fn extension(&self) -> Option<&'static str> { match self.mime_type.as_str() { - "image/png" | "image/x-png" => "png", - "image/jpeg" | "image/pjpeg" => "jpg", - "image/gif" => "gif", - "image/bmp" | "image/x-bmp" | "image/x-ms-bmp" => "bmp", - "image/webp" => "webp", - "image/tiff" | "image/x-tiff" => "tiff", - "image/svg+xml" => "svg", - "image/x-icon" | "image/vnd.microsoft.icon" => "ico", - "image/avif" => "avif", - "image/heic" => "heic", - "image/heif" => "heif", - "image/jxl" => "jxl", - _ => "png", // Default to png + "image/png" | "image/x-png" => Some("png"), + "image/jpeg" | "image/pjpeg" => Some("jpg"), + "image/gif" => Some("gif"), + "image/bmp" | "image/x-bmp" | "image/x-ms-bmp" => Some("bmp"), + "image/webp" => Some("webp"), + "image/tiff" | "image/x-tiff" => Some("tiff"), + "image/svg+xml" => Some("svg"), + "image/x-icon" | "image/vnd.microsoft.icon" => Some("ico"), + "image/avif" => Some("avif"), + "image/heic" => Some("heic"), + "image/heif" => Some("heif"), + "image/jxl" => Some("jxl"), + _ => None, } } } @@ -272,20 +273,21 @@ impl TryFrom<(Vec, String)> for ClipboardPasteVideo { impl ClipboardPasteVideo { /// Get the file extension for the video based on MIME type. - pub fn extension(&self) -> &'static str { + /// Returns None if the MIME type is not recognized. + pub fn extension(&self) -> Option<&'static str> { match self.mime_type.as_str() { - "video/mp4" => "mp4", - "video/webm" => "webm", - "video/ogg" => "ogv", - "video/mpeg" => "mpeg", - "video/quicktime" => "mov", - "video/x-msvideo" | "video/avi" => "avi", - "video/x-matroska" => "mkv", - "video/x-flv" => "flv", - "video/3gpp" => "3gp", - "video/3gpp2" => "3g2", - "video/x-ms-wmv" => "wmv", - _ => "mp4", // Default to mp4 + "video/mp4" => Some("mp4"), + "video/webm" => Some("webm"), + "video/ogg" => Some("ogv"), + "video/mpeg" => Some("mpeg"), + "video/quicktime" => Some("mov"), + "video/x-msvideo" | "video/avi" => Some("avi"), + "video/x-matroska" => Some("mkv"), + "video/x-flv" => Some("flv"), + "video/3gpp" => Some("3gp"), + "video/3gpp2" => Some("3g2"), + "video/x-ms-wmv" => Some("wmv"), + _ => None, } } } From 969c72be028ead6a78928c1eea1c71da3bc94fab Mon Sep 17 00:00:00 2001 From: Frederic Laing Date: Wed, 4 Feb 2026 08:34:40 +0100 Subject: [PATCH 3/3] fix: handle invalid UTF-8 in clipboard text paste --- src/clipboard.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/clipboard.rs b/src/clipboard.rs index fd26a0e..bf84bee 100644 --- a/src/clipboard.rs +++ b/src/clipboard.rs @@ -317,9 +317,11 @@ impl TryFrom<(Vec, String)> for ClipboardPasteText { if data.is_empty() { return Err("Empty text data".into()); } - let text = str::from_utf8(&data)?; + // Use lossy conversion to handle clipboard data that may contain + // invalid UTF-8 (e.g., Latin-1 encoded special characters from browsers) + let text = String::from_utf8_lossy(&data); Ok(Self { - data: text.to_string(), + data: text.into_owned(), }) } }