Fix image and file paste in Studio desktop (#7543)

* Fix Studio desktop clipboard paste

* Address clipboard paste review findings
This commit is contained in:
Wasim Yousef Said 2026-07-28 13:46:51 +02:00 committed by GitHub
commit af2439683a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 826 additions and 1 deletions

View file

@ -36,6 +36,7 @@ import { TerminalToolUI } from "@/components/assistant-ui/tool-ui-terminal";
import { WebSearchToolUI } from "@/components/assistant-ui/tool-ui-web-search";
import { ChatDictationBar } from "@/components/assistant-ui/chat-dictation-bar";
import {
pasteClipboardFiles,
isStudioDictationAvailable,
notifyStudioDictationUnavailable,
} from "@/features/chat";
@ -177,6 +178,7 @@ import {
type ChangeEvent,
type ComponentProps,
type CompositionEvent,
type ClipboardEvent,
type FC,
type KeyboardEvent,
type DragEvent as ReactDragEvent,
@ -1528,6 +1530,24 @@ const Composer: FC<{
);
const { inputProps, isComposing, isComposingRef } =
useImeComposerInputHandlers({ submitOnEnter: true });
const handleFilePaste = useCallback(
(event: ClipboardEvent<HTMLTextAreaElement>) => {
pasteClipboardFiles(
event,
async (files) => {
await Promise.all(
files.map((file) => aui.composer().addAttachment(file)),
);
},
() =>
toast.error("Could not paste files.", {
description: "The clipboard item is unsupported, unreadable, or over 20 MB.",
}),
);
},
[aui],
);
const composerText = useAuiState(({ composer }) => composer.text);
// Expand only once the input wraps to a second line, not on first keystroke.
// Latch until cleared so it can't flip-flop at the wrap boundary.
@ -2021,6 +2041,8 @@ const Composer: FC<{
// no effect on Latin / CJK / Devanagari.
dir="auto"
{...inputProps}
addAttachmentOnPaste={false}
onPaste={handleFilePaste}
/>
<ComposerRightControls
disabled={

View file

@ -88,6 +88,7 @@ export { StopRunningChatsDialog } from "./components/stop-running-chats-dialog";
export { setTrainingCompareHandoff } from "./lib/training-compare-handoff";
export type { ProjectRecord } from "./types";
export { clearAllChats, countAllChats } from "./utils/clear-all-chats";
export { pasteClipboardFiles } from "./utils/clipboard-files";
export { listStoredChatThreads } from "./utils/chat-history-storage";
export { emitChatAttachmentDeleted } from "./utils/chat-attachment-events";
export { ArtifactCard } from "./artifacts/artifact-card";

View file

@ -35,6 +35,7 @@ import { isTauri } from "@/lib/api-base";
import { isDownloadCancelled } from "@/lib/native-files";
import { isMultimodalResponse } from "./types/api";
import { getImageInputUnavailableReason } from "./utils/image-input-support";
import { pasteClipboardFiles } from "./utils/clipboard-files";
import { useAui } from "@assistant-ui/react";
import {
ArrowUpIcon,
@ -118,6 +119,7 @@ import {
} from "./provider-capabilities";
import {
type CompositionEvent,
type ClipboardEvent,
type FC,
type KeyboardEvent,
type MutableRefObject,
@ -834,7 +836,7 @@ export function SharedComposer({
}, [text]);
const addFiles = useCallback(
(files: FileList | null) => {
(files: FileList | readonly File[] | null) => {
if (!files?.length) return;
const next: PendingImage[] = [];
let droppedImageForUnavailable = false;
@ -866,6 +868,29 @@ export function SharedComposer({
[setPendingAudioStore, attachUnavailableReason],
);
const handleFilePaste = useCallback(
(event: ClipboardEvent<HTMLTextAreaElement>) => {
pasteClipboardFiles(
event,
async (files) => {
const supported = files.some(
(file) =>
(file.type.match(/^audio\//i) && file.size <= MAX_AUDIO_SIZE) ||
(file.type.match(/^image\/(jpeg|png|webp|gif)$/i) &&
file.size <= MAX_IMAGE_SIZE),
);
if (!supported) throw new Error("Unsupported compare attachment");
addFiles(files);
},
() =>
toast.error("Could not paste files.", {
description: "Compare supports images and audio within the attachment size limits.",
}),
);
},
[addFiles],
);
const removePendingImage = useCallback((id: string) => {
setPendingImages((prev) => prev.filter((p) => p.id !== id));
}, []);
@ -1688,6 +1713,7 @@ export function SharedComposer({
setText(e.currentTarget.value);
}}
onKeyDown={onKeyDown}
onPaste={handleFilePaste}
onBlur={() => {
// Mac: switching input methods can fire compositionstart without a
// matching compositionend, leaving composingRef pinned. The OS always

View file

@ -0,0 +1,248 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { isTauri } from "@/lib/api-base";
const MAX_NATIVE_IMAGE_DIMENSION = 8192;
const MAX_NATIVE_IMAGE_RGBA_BYTES = 64 * 1024 * 1024;
const MAX_CLIPBOARD_BYTES = 20 * 1024 * 1024;
const MAX_CLIPBOARD_FILES = 8;
type ClipboardPasteEvent = {
readonly clipboardData: DataTransfer | null;
readonly defaultPrevented: boolean;
readonly isTrusted: boolean;
preventDefault: () => void;
};
type NativeClipboardFile = {
readonly name: string;
readonly mimeType: string;
readonly base64: string;
};
function browserClipboardFiles(clipboardData: DataTransfer): File[] {
const files = Array.from(clipboardData.files).filter((file) => file.size > 0);
if (files.length > 0) return files;
return Array.from(clipboardData.items)
.filter((item) => item.kind === "file")
.map((item) => item.getAsFile())
.filter((file): file is File => file !== null && file.size > 0);
}
function clipboardTypes(clipboardData: DataTransfer): string[] {
return Array.from(clipboardData.types, (type) => type.toLowerCase());
}
function clipboardHasLocalFileUri(
clipboardData: DataTransfer,
types: readonly string[],
): boolean {
const uriTypes = types.filter(
(type) => type.includes("uri-list") || type.includes("urilist"),
);
for (const type of uriTypes) {
try {
if (
clipboardData
.getData(type)
.split(/\r?\n/)
.some((line) => line.trim().toLowerCase().startsWith("file:"))
) {
return true;
}
} catch {
return false;
}
}
return false;
}
function clipboardHasPlainText(clipboardData: DataTransfer): boolean {
try {
return clipboardData.getData("text/plain").length > 0;
} catch {
return true;
}
}
function validDimension(value: number): boolean {
return (
Number.isSafeInteger(value) &&
value > 0 &&
value <= MAX_NATIVE_IMAGE_DIMENSION
);
}
function canvasPng(canvas: HTMLCanvasElement): Promise<Blob | null> {
return new Promise((resolve) => canvas.toBlob(resolve, "image/png"));
}
function isLinuxDesktop(): boolean {
if (typeof navigator === "undefined") return false;
return `${navigator.platform} ${navigator.userAgent}`.toLowerCase().includes("linux");
}
async function readNativeClipboardFiles(): Promise<File[]> {
const { invoke } = await import("@tauri-apps/api/core");
const nativeFiles = await invoke<NativeClipboardFile[]>(
"read_native_clipboard_files",
);
if (nativeFiles.length > MAX_CLIPBOARD_FILES) return [];
let totalBytes = 0;
const files: File[] = [];
for (const file of nativeFiles) {
if (
!file.name ||
file.name.length > 255 ||
file.name.includes("/") ||
file.name.includes("\0") ||
file.base64.length > Math.ceil((MAX_CLIPBOARD_BYTES * 4) / 3) + 4
) {
return [];
}
const binary = globalThis.atob(file.base64);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
totalBytes += bytes.byteLength;
if (totalBytes > MAX_CLIPBOARD_BYTES) return [];
files.push(
new File([bytes], file.name, {
type: file.mimeType || "application/octet-stream",
lastModified: Date.now(),
}),
);
}
return files;
}
async function readLinuxClipboardImage(): Promise<File | null> {
const { invoke } = await import("@tauri-apps/api/core");
const raw = await invoke<ArrayBuffer | Uint8Array>("read_native_clipboard_png");
const png = Uint8Array.from(raw instanceof Uint8Array ? raw : new Uint8Array(raw));
if (png.byteLength === 0 || png.byteLength > MAX_CLIPBOARD_BYTES) return null;
return new File([png], "pasted-image.png", {
type: "image/png",
lastModified: Date.now(),
});
}
async function readNativeClipboardImage(): Promise<File | null> {
let image: Awaited<ReturnType<
typeof import("@tauri-apps/plugin-clipboard-manager").readImage
>> | null = null;
try {
if (isLinuxDesktop()) return await readLinuxClipboardImage();
const { readImage } = await import("@tauri-apps/plugin-clipboard-manager");
image = await readImage();
const { width, height } = await image.size();
if (!validDimension(width) || !validDimension(height)) return null;
const expectedRgbaBytes = width * height * 4;
if (expectedRgbaBytes > MAX_NATIVE_IMAGE_RGBA_BYTES) return null;
const rgba = await image.rgba();
if (rgba.byteLength !== expectedRgbaBytes) return null;
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
try {
const context = canvas.getContext("2d");
if (!context) return null;
const pixels = new Uint8ClampedArray(
rgba.buffer as ArrayBuffer,
rgba.byteOffset,
rgba.byteLength,
);
context.putImageData(new ImageData(pixels, width, height), 0, 0);
const blob = await canvasPng(canvas);
if (!blob || blob.size === 0 || blob.size > MAX_CLIPBOARD_BYTES) {
return null;
}
return new File([blob], "pasted-image.png", {
type: "image/png",
lastModified: Date.now(),
});
} finally {
canvas.width = 0;
canvas.height = 0;
}
} catch {
return null;
} finally {
if (image) {
try {
await image.close();
} catch {
// The native resource may already have been released after an invoke failure.
}
}
}
}
function addClipboardFiles(
files: readonly File[],
addFiles: (files: readonly File[]) => void | Promise<void>,
onError?: () => void,
): void {
void Promise.resolve(addFiles(files)).catch(() => onError?.());
}
function addNativeClipboardFiles(
addFiles: (files: readonly File[]) => void | Promise<void>,
onError?: () => void,
): void {
void (async () => {
try {
const files = await readNativeClipboardFiles();
if (files.length > 0) return files;
} catch {
// The clipboard may contain image pixels instead of file paths.
}
const image = await readNativeClipboardImage();
return image ? [image] : [];
})().then((files) => {
if (files.length > 0) addClipboardFiles(files, addFiles, onError);
else onError?.();
});
}
export function pasteClipboardFiles(
event: ClipboardPasteEvent,
addFiles: (files: readonly File[]) => void | Promise<void>,
onError?: () => void,
): void {
const { clipboardData } = event;
if (clipboardData) {
const browserFiles = browserClipboardFiles(clipboardData);
if (browserFiles.length > 0) {
event.preventDefault();
addClipboardFiles(browserFiles, addFiles, onError);
return;
}
}
if (!isTauri || !event.isTrusted || event.defaultPrevented) return;
if (!clipboardData) {
addNativeClipboardFiles(addFiles, onError);
return;
}
const types = clipboardTypes(clipboardData);
const advertisesImage = types.some((type) => type.startsWith("image/"));
const advertisesFile =
types.includes("files") ||
types.some((type) => type.includes("copied-files")) ||
clipboardHasLocalFileUri(clipboardData, types);
if (!advertisesImage && !advertisesFile && clipboardHasPlainText(clipboardData)) {
return;
}
if (advertisesImage || advertisesFile) event.preventDefault();
addNativeClipboardFiles(addFiles, onError);
}

View file

@ -5566,10 +5566,15 @@ checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
name = "unsloth-studio"
version = "2026.4.8"
dependencies = [
"arboard",
"base64 0.22.1",
"dirs",
"elevated-command",
"fix-path-env",
"gdk",
"gdk-pixbuf",
"glib",
"gtk",
"hmac",
"libc",
"log",

View file

@ -26,6 +26,7 @@ fix-path-env = { git = "https://github.com/tauri-apps/fix-path-env-rs" }
tauri-plugin-opener = "2.5.4"
tauri-plugin-updater = "2"
tauri-plugin-clipboard-manager = "2"
arboard = "3.6.1"
tauri-plugin-dialog = "2"
rand = "0.10.0"
tauri-plugin-notification = "2.3.3"
@ -36,6 +37,10 @@ tauri-plugin-window-state = "2"
libc = "0.2"
[target.'cfg(target_os = "linux")'.dependencies]
gdk = "0.18"
gdk-pixbuf = "0.18"
glib = "0.18"
gtk = "0.18"
elevated-command = "1.1.2"
[target.'cfg(windows)'.dependencies]

View file

@ -29,6 +29,7 @@
},
"updater:default",
"clipboard-manager:allow-write-text",
"clipboard-manager:allow-read-image",
"window-state:default"
]
}

View file

@ -8,6 +8,7 @@ mod desktop_update_policy;
mod diagnostics;
mod install;
mod native_backend_lease;
mod native_clipboard;
mod native_file_dialogs;
mod native_intents;
mod native_path_policy;
@ -218,6 +219,8 @@ fn main() {
desktop_update_policy::check_desktop_manual_update,
desktop_update_policy::desktop_update_policy,
diagnostics::collect_support_diagnostics,
native_clipboard::read_native_clipboard_files,
native_clipboard::read_native_clipboard_png,
native_file_dialogs::save_native_file,
native_file_dialogs::pick_native_chat_import,
native_intents::drain_native_intents,

View file

@ -0,0 +1,442 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
use serde::Serialize;
use std::fs::File;
use std::io::Read;
use std::path::{Path, PathBuf};
const MAX_CLIPBOARD_IMAGE_DIMENSION: i32 = 8192;
const MAX_CLIPBOARD_RGBA_BYTES: u64 = 64 * 1024 * 1024;
const MAX_CLIPBOARD_PNG_BYTES: usize = 20 * 1024 * 1024;
const MAX_CLIPBOARD_SOURCE_BYTES: u64 = 20 * 1024 * 1024;
const MAX_CLIPBOARD_TOTAL_BYTES: u64 = 20 * 1024 * 1024;
const MAX_CLIPBOARD_FILES: usize = 8;
const MAX_CLIPBOARD_CANDIDATES: usize = 32;
#[cfg(target_os = "linux")]
const MAX_CLIPBOARD_URI_BYTES: usize = 64 * 1024;
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct NativeClipboardFile {
name: String,
mime_type: String,
base64: String,
}
fn validate_dimensions(width: i32, height: i32) -> Result<(), String> {
if width <= 0
|| height <= 0
|| width > MAX_CLIPBOARD_IMAGE_DIMENSION
|| height > MAX_CLIPBOARD_IMAGE_DIMENSION
{
return Err("Clipboard image dimensions are invalid or too large.".to_string());
}
let rgba_bytes = (width as u64)
.checked_mul(height as u64)
.and_then(|pixels| pixels.checked_mul(4))
.ok_or_else(|| "Clipboard image dimensions overflow.".to_string())?;
if rgba_bytes > MAX_CLIPBOARD_RGBA_BYTES {
return Err("Clipboard image pixel data is too large.".to_string());
}
Ok(())
}
#[cfg(target_os = "linux")]
fn validate_png_bytes(png: &[u8]) -> Result<(), String> {
const SIGNATURE: &[u8; 8] = b"\x89PNG\r\n\x1a\n";
if png.len() < 24 || png.len() > MAX_CLIPBOARD_PNG_BYTES || &png[..8] != SIGNATURE {
return Err("Clipboard PNG data is invalid or too large.".to_string());
}
if &png[12..16] != b"IHDR" {
return Err("Clipboard PNG header is invalid.".to_string());
}
let width = u32::from_be_bytes(png[16..20].try_into().unwrap());
let height = u32::from_be_bytes(png[20..24].try_into().unwrap());
let width = i32::try_from(width).map_err(|_| "Clipboard PNG width is invalid.".to_string())?;
let height =
i32::try_from(height).map_err(|_| "Clipboard PNG height is invalid.".to_string())?;
validate_dimensions(width, height)
}
fn clipboard_file_mime_type(path: &Path) -> Option<&'static str> {
let extension = path
.extension()
.and_then(|value| value.to_str())
.unwrap_or_default()
.to_ascii_lowercase();
let mime_type = match extension.as_str() {
"json" | "jsonl" | "ndjson" => "application/json",
"md" | "markdown" | "mdx" => "text/markdown",
"csv" => "text/csv",
"html" | "htm" => "text/html",
"xml" => "application/xml",
"svg" => "image/svg+xml",
"jpg" | "jpeg" => "image/jpeg",
"png" => "image/png",
"webp" => "image/webp",
"gif" => "image/gif",
"pdf" => "application/pdf",
"docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"odt" => "application/vnd.oasis.opendocument.text",
"ods" => "application/vnd.oasis.opendocument.spreadsheet",
"mp3" => "audio/mpeg",
"wav" => "audio/wav",
"m4a" => "audio/mp4",
"ogg" | "oga" => "audio/ogg",
"flac" => "audio/flac",
"aac" => "audio/aac",
"txt" | "text" | "log" | "rst" | "tsv" | "yaml" | "yml" | "toml" | "ini" | "cfg"
| "conf" | "env" | "properties" | "css" | "scss" | "sass" | "less" | "js" | "jsx"
| "mjs" | "cjs" | "ts" | "tsx" | "py" | "pyi" | "ipynb" | "rb" | "php" | "go" | "rs"
| "java" | "kt" | "kts" | "scala" | "swift" | "c" | "h" | "cc" | "cpp" | "hpp" | "cxx"
| "cs" | "m" | "mm" | "sh" | "bash" | "zsh" | "fish" | "ps1" | "bat" | "lua" | "pl"
| "pm" | "r" | "jl" | "dart" | "vue" | "svelte" | "astro" | "sql" | "graphql" | "gql"
| "proto" | "tf" | "tfvars" | "gradle" | "dockerfile" | "makefile" | "cmake" | "diff"
| "patch" => "text/plain",
_ => return None,
};
Some(mime_type)
}
fn open_regular_clipboard_file(path: &Path) -> Option<File> {
let metadata = std::fs::symlink_metadata(path).ok()?;
if metadata.file_type().is_symlink() || !metadata.is_file() {
return None;
}
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
std::fs::OpenOptions::new()
.read(true)
.custom_flags(libc::O_NONBLOCK | libc::O_NOFOLLOW)
.open(path)
.ok()
}
#[cfg(not(unix))]
{
File::open(path).ok()
}
}
fn read_clipboard_files(paths: Vec<PathBuf>) -> Result<Vec<NativeClipboardFile>, String> {
let mut remaining = MAX_CLIPBOARD_TOTAL_BYTES;
let mut files = Vec::new();
for path in paths.into_iter().take(MAX_CLIPBOARD_CANDIDATES) {
if remaining == 0 || files.len() >= MAX_CLIPBOARD_FILES {
break;
}
let Some(name) = path
.file_name()
.map(|value| value.to_string_lossy().into_owned())
else {
continue;
};
let Some(mime_type) = clipboard_file_mime_type(&path) else {
continue;
};
let Some(source) = open_regular_clipboard_file(&path) else {
continue;
};
let Ok(metadata) = source.metadata() else {
continue;
};
let limit = MAX_CLIPBOARD_SOURCE_BYTES.min(remaining);
if !metadata.is_file() || metadata.len() > limit {
continue;
}
let mut bytes = Vec::with_capacity(metadata.len() as usize);
if source.take(limit + 1).read_to_end(&mut bytes).is_err() || bytes.len() as u64 > limit {
continue;
}
remaining -= bytes.len() as u64;
files.push(NativeClipboardFile {
name,
mime_type: mime_type.to_string(),
base64: BASE64.encode(bytes),
});
}
if files.is_empty() {
return Err("Clipboard does not contain readable local files.".to_string());
}
Ok(files)
}
#[cfg(target_os = "linux")]
fn encode_clipboard_pixbuf(image: &gdk_pixbuf::Pixbuf) -> Result<Vec<u8>, String> {
validate_dimensions(image.width(), image.height())?;
let png = image
.save_to_bufferv("png", &[])
.map_err(|error| format!("Could not encode clipboard image: {error}"))?;
if png.is_empty() || png.len() > MAX_CLIPBOARD_PNG_BYTES {
return Err("Clipboard image encoding is empty or too large.".to_string());
}
Ok(png)
}
#[cfg(target_os = "linux")]
fn local_clipboard_path(uri: &str) -> Option<PathBuf> {
let (path, hostname) = glib::filename_from_uri(uri).ok()?;
hostname.is_none().then_some(path)
}
#[cfg(target_os = "linux")]
fn local_clipboard_paths_from_bytes(data: &[u8]) -> Vec<PathBuf> {
if data.len() > MAX_CLIPBOARD_URI_BYTES {
return Vec::new();
}
let Ok(text) = std::str::from_utf8(data) else {
return Vec::new();
};
text.lines()
.map(|line| {
line.trim_matches(|character: char| character.is_whitespace() || character == '\0')
})
.filter_map(local_clipboard_path)
.take(MAX_CLIPBOARD_CANDIDATES)
.collect()
}
#[cfg(target_os = "linux")]
fn read_gtk_clipboard_paths() -> Vec<PathBuf> {
let clipboard = gtk::Clipboard::get(&gdk::SELECTION_CLIPBOARD);
let mut paths: Vec<PathBuf> = clipboard
.wait_for_uris()
.into_iter()
.filter_map(|uri| local_clipboard_path(uri.as_str()))
.take(MAX_CLIPBOARD_CANDIDATES)
.collect();
for target in clipboard.wait_for_targets().unwrap_or_default() {
if paths.len() >= MAX_CLIPBOARD_CANDIDATES {
break;
}
if !target.name().to_ascii_lowercase().contains("copied-files") {
continue;
}
let Some(data) = clipboard.wait_for_contents(&target) else {
continue;
};
let length = data.length();
if length <= 0 || length as usize > MAX_CLIPBOARD_URI_BYTES {
continue;
}
for path in local_clipboard_paths_from_bytes(&data.data()) {
if !paths.contains(&path) {
paths.push(path);
}
}
}
paths.truncate(MAX_CLIPBOARD_CANDIDATES);
paths
}
#[cfg(target_os = "linux")]
async fn native_clipboard_paths() -> Result<Vec<PathBuf>, String> {
let (tx, rx) = tokio::sync::oneshot::channel();
glib::MainContext::default().invoke(move || {
let _ = tx.send(read_gtk_clipboard_paths());
});
rx.await
.map_err(|_| "Clipboard file reader stopped unexpectedly.".to_string())
}
#[cfg(not(target_os = "linux"))]
async fn native_clipboard_paths() -> Result<Vec<PathBuf>, String> {
tokio::task::spawn_blocking(|| {
let mut clipboard = arboard::Clipboard::new().map_err(|error| error.to_string())?;
let mut paths = clipboard
.get()
.file_list()
.map_err(|error| error.to_string())?;
paths.truncate(MAX_CLIPBOARD_CANDIDATES);
Ok(paths)
})
.await
.map_err(|_| "Clipboard file reader stopped unexpectedly.".to_string())?
}
#[tauri::command]
pub async fn read_native_clipboard_files(
window: tauri::WebviewWindow,
) -> Result<Vec<NativeClipboardFile>, String> {
crate::native_intents::ensure_main_window(&window)?;
let paths = native_clipboard_paths().await?;
tokio::task::spawn_blocking(move || read_clipboard_files(paths))
.await
.map_err(|_| "Clipboard file loader stopped unexpectedly.".to_string())?
}
#[cfg(target_os = "linux")]
fn read_gtk_clipboard_file_image() -> Result<gdk_pixbuf::Pixbuf, String> {
use std::os::fd::AsRawFd;
for path in read_gtk_clipboard_paths() {
let Some(source) = open_regular_clipboard_file(&path) else {
continue;
};
let Ok(metadata) = source.metadata() else {
continue;
};
if !metadata.is_file() || metadata.len() > MAX_CLIPBOARD_SOURCE_BYTES {
continue;
}
let descriptor_path = PathBuf::from(format!("/proc/self/fd/{}", source.as_raw_fd()));
let Some((_, width, height)) = gdk_pixbuf::Pixbuf::file_info(&descriptor_path) else {
continue;
};
if validate_dimensions(width, height).is_err() {
continue;
}
let Ok(image) = gdk_pixbuf::Pixbuf::from_file(&descriptor_path) else {
continue;
};
if validate_dimensions(image.width(), image.height()).is_ok() {
return Ok(image);
}
}
Err("Clipboard does not contain a readable image or local image file.".to_string())
}
#[cfg(target_os = "linux")]
fn read_gtk_clipboard_png() -> Result<Vec<u8>, String> {
let clipboard = gtk::Clipboard::get(&gdk::SELECTION_CLIPBOARD);
let png_target = gdk::Atom::intern("image/png");
if let Some(data) = clipboard.wait_for_contents(&png_target) {
let length = data.length();
if length <= 0 || length as usize > MAX_CLIPBOARD_PNG_BYTES {
return Err("Clipboard PNG data is empty or too large.".to_string());
}
let png = data.data();
validate_png_bytes(&png)?;
return Ok(png);
}
let image = match clipboard.wait_for_image() {
Some(image) => image,
None => read_gtk_clipboard_file_image()?,
};
encode_clipboard_pixbuf(&image)
}
#[cfg(target_os = "linux")]
#[tauri::command]
pub async fn read_native_clipboard_png(
window: tauri::WebviewWindow,
) -> Result<tauri::ipc::Response, String> {
crate::native_intents::ensure_main_window(&window)?;
let (tx, rx) = tokio::sync::oneshot::channel();
glib::MainContext::default().invoke(move || {
let _ = tx.send(read_gtk_clipboard_png());
});
let png = rx
.await
.map_err(|_| "Clipboard image reader stopped unexpectedly.".to_string())??;
Ok(tauri::ipc::Response::new(png))
}
#[cfg(not(target_os = "linux"))]
#[tauri::command]
pub async fn read_native_clipboard_png(
window: tauri::WebviewWindow,
) -> Result<tauri::ipc::Response, String> {
crate::native_intents::ensure_main_window(&window)?;
Err("Native PNG clipboard fallback is only available on Linux.".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn clipboard_dimensions_are_bounded() {
assert!(validate_dimensions(3840, 2160).is_ok());
assert!(validate_dimensions(0, 100).is_err());
assert!(validate_dimensions(8193, 100).is_err());
assert!(validate_dimensions(8192, 8192).is_err());
}
#[test]
fn clipboard_file_mime_types_cover_text_attachments() {
assert_eq!(
clipboard_file_mime_type(Path::new("data.json")),
Some("application/json")
);
assert_eq!(
clipboard_file_mime_type(Path::new("notes.md")),
Some("text/markdown")
);
assert_eq!(clipboard_file_mime_type(Path::new("unknown.bin")), None);
}
#[cfg(target_os = "linux")]
#[test]
fn clipboard_png_headers_are_bounded_before_decode() {
let mut png = vec![0; 24];
png[..8].copy_from_slice(b"\x89PNG\r\n\x1a\n");
png[12..16].copy_from_slice(b"IHDR");
png[16..20].copy_from_slice(&1920_u32.to_be_bytes());
png[20..24].copy_from_slice(&1080_u32.to_be_bytes());
assert!(validate_png_bytes(&png).is_ok());
png[16..20].copy_from_slice(&9000_u32.to_be_bytes());
assert!(validate_png_bytes(&png).is_err());
}
#[test]
fn clipboard_file_reads_are_bounded() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("notes.md");
std::fs::write(&path, b"clipboard text").unwrap();
let oversized = directory.path().join("oversized.md");
File::create(&oversized)
.unwrap()
.set_len(MAX_CLIPBOARD_SOURCE_BYTES + 1)
.unwrap();
let files =
read_clipboard_files(vec![directory.path().to_path_buf(), oversized, path]).unwrap();
assert_eq!(files.len(), 1);
assert_eq!(files[0].name, "notes.md");
assert_eq!(files[0].mime_type, "text/markdown");
assert_eq!(BASE64.decode(&files[0].base64).unwrap(), b"clipboard text");
}
#[cfg(unix)]
#[test]
fn clipboard_file_reads_reject_symlinks() {
let directory = tempfile::tempdir().unwrap();
let target = directory.path().join("target.md");
let link = directory.path().join("link.md");
std::fs::write(&target, b"clipboard text").unwrap();
std::os::unix::fs::symlink(&target, &link).unwrap();
assert!(open_regular_clipboard_file(&link).is_none());
assert!(read_clipboard_files(vec![link]).is_err());
}
#[cfg(target_os = "linux")]
#[test]
fn copied_file_targets_parse_local_uris() {
let paths = local_clipboard_paths_from_bytes(
b"copy\nfile:///tmp/pasted%20notes.md\nhttps://example.com/ignored.md\0",
);
assert_eq!(paths, vec![PathBuf::from("/tmp/pasted notes.md")]);
assert!(
local_clipboard_paths_from_bytes(&vec![b'x'; MAX_CLIPBOARD_URI_BYTES + 1]).is_empty()
);
}
#[cfg(target_os = "linux")]
#[test]
fn clipboard_file_uris_must_be_local() {
assert_eq!(
local_clipboard_path("file:///tmp/pasted%20image.png"),
Some(PathBuf::from("/tmp/pasted image.png"))
);
assert!(local_clipboard_path("file://remote/tmp/image.png").is_none());
assert!(local_clipboard_path("https://example.com/image.png").is_none());
}
}

View file

@ -20,10 +20,15 @@ THREAD_SIDEBAR = FRONTEND / "features/chat/thread-sidebar.tsx"
SHARED_COMPOSER = FRONTEND / "features/chat/shared-composer.tsx"
TITLEBAR = FRONTEND / "components/tauri/window-titlebar.tsx"
NATIVE_DIALOGS = REPO / "studio/src-tauri/src/native_file_dialogs.rs"
NATIVE_CLIPBOARD = REPO / "studio/src-tauri/src/native_clipboard.rs"
TAURI_MAIN = REPO / "studio/src-tauri/src/main.rs"
APP_PROVIDER = FRONTEND / "app/provider.tsx"
CLIPBOARD_FILES = FRONTEND / "features/chat/utils/clipboard-files.ts"
TAURI_CAPABILITIES = REPO / "studio/src-tauri/capabilities/default.json"
def test_file_actions_route_through_native_commands_only_in_tauri():
helper = NATIVE_FILES.read_text(encoding = "utf-8")
@ -98,6 +103,73 @@ def test_chat_exports_await_native_saves_and_markdown_uses_shared_helper():
assert "downloadFile(" in thread
def test_clipboard_file_paste_is_bounded_and_wired_to_both_composers():
helper = CLIPBOARD_FILES.read_text(encoding = "utf-8")
thread = THREAD.read_text(encoding = "utf-8")
shared_composer = SHARED_COMPOSER.read_text(encoding = "utf-8")
capabilities = TAURI_CAPABILITIES.read_text(encoding = "utf-8")
for contract in (
"clipboardData.files",
"clipboardData.items",
"item.getAsFile()",
"file.size > 0",
'clipboardData.getData("text/plain")',
"event.isTrusted",
"event.defaultPrevented",
'types.includes("files")',
'type.includes("uri-list")',
'"read_native_clipboard_files"',
"globalThis.atob(file.base64)",
"new File([bytes], file.name",
"MAX_CLIPBOARD_BYTES",
'import("@tauri-apps/plugin-clipboard-manager")',
"await readImage()",
"rgba.byteLength !== expectedRgbaBytes",
"await image.close()",
):
assert contract in helper
assert "addAttachmentOnPaste={false}" in thread
assert "onPaste={handleFilePaste}" in thread
assert "pasteClipboardFiles" in thread
assert "aui.composer().addAttachment(file)" in thread
assert "onPaste={handleFilePaste}" in shared_composer
assert "pasteClipboardFiles" in shared_composer
assert "addFiles(files)" in shared_composer
assert capabilities.count('"clipboard-manager:allow-read-image"') == 1
assert '"clipboard-manager:allow-read-text"' not in capabilities
def test_native_clipboard_bridge_is_bounded_and_registered():
native_clipboard = NATIVE_CLIPBOARD.read_text(encoding = "utf-8")
tauri_main = TAURI_MAIN.read_text(encoding = "utf-8")
for contract in (
"MAX_CLIPBOARD_FILES",
"MAX_CLIPBOARD_URI_BYTES",
"MAX_CLIPBOARD_TOTAL_BYTES",
"MAX_CLIPBOARD_SOURCE_BYTES",
"MAX_CLIPBOARD_RGBA_BYTES",
".take(limit + 1)",
".wait_for_uris()",
".wait_for_targets()",
'contains("copied-files")',
"open_regular_clipboard_file(&path)",
'"/proc/self/fd/{}"',
".wait_for_image()",
"glib::filename_from_uri",
"glib::MainContext::default().invoke",
"arboard::Clipboard::new()",
"BASE64.encode(bytes)",
"tauri::ipc::Response::new(png)",
):
assert contract in native_clipboard
assert "native_clipboard::read_native_clipboard_files" in tauri_main
assert "native_clipboard::read_native_clipboard_png" in tauri_main
def test_desktop_startup_waits_for_auth_without_intermediate_handoff():
source = APP_PROVIDER.read_text(encoding = "utf-8")