Fix image and file paste in Studio desktop (#7543)
* Fix Studio desktop clipboard paste * Address clipboard paste review findings
This commit is contained in:
parent
c608649552
commit
af2439683a
10 changed files with 826 additions and 1 deletions
|
|
@ -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={
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
248
studio/frontend/src/features/chat/utils/clipboard-files.ts
Normal file
248
studio/frontend/src/features/chat/utils/clipboard-files.ts
Normal 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);
|
||||
}
|
||||
5
studio/src-tauri/Cargo.lock
generated
5
studio/src-tauri/Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@
|
|||
},
|
||||
"updater:default",
|
||||
"clipboard-manager:allow-write-text",
|
||||
"clipboard-manager:allow-read-image",
|
||||
"window-state:default"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
442
studio/src-tauri/src/native_clipboard.rs
Normal file
442
studio/src-tauri/src/native_clipboard.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue