use crate::ops::{ClipboardMode, ClipboardPayload};
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::sync::mpsc::{self, Receiver, SyncSender, TryRecvError};
use std::thread;
use std::time::{Duration, Instant};
use x11rb::connection::Connection;
use x11rb::protocol::xproto::{
Atom, AtomEnum, ConnectionExt as _, CreateWindowAux, EventMask, PropMode, SelectionNotifyEvent,
SelectionRequestEvent, Window, WindowClass, SELECTION_NOTIFY_EVENT,
};
use x11rb::protocol::Event;
use x11rb::rust_connection::RustConnection;
use x11rb::wrapper::ConnectionExt as _;
const FILE_LIMIT: usize = 1_024;
const CLIPBOARD_BYTES_LIMIT: usize = 128 * 1024;
const EVENT_LIMIT: usize = 64;
const REQUEST_TIMEOUT: Duration = Duration::from_secs(3);
x11rb::atom_manager! {
Atoms: AtomsCookie {
CLIPBOARD,
TARGETS,
TIMESTAMP,
SAVE_TARGETS,
UTF8_STRING,
GUTH_CLIPBOARD_TIMESTAMP,
GUTH_CLIPBOARD_RESPONSE,
GnomeCopiedFiles: b"x-special/gnome-copied-files",
TextUriList: b"text/uri-list",
}
}
#[derive(Clone, Debug)]
pub struct ClipboardRead {
pub payload: ClipboardPayload,
pub owner: Window,
}
pub struct DesktopFileClipboard {
commands: SyncSender<OwnerCommand>,
ownership_events: Receiver<()>,
}
enum OwnerCommand {
Publish(ClipboardPayload, SyncSender<Result<(), String>>),
Clear(Window),
Shutdown,
}
struct OwnedClipboard {
gnome: Vec<u8>,
uri_list: Vec<u8>,
text: Vec<u8>,
timestamp: u32,
}
struct ClipboardFormats {
gnome: Vec<u8>,
uri_list: Vec<u8>,
text: Vec<u8>,
}
impl DesktopFileClipboard {
pub fn new() -> Result<Self, String> {
let (commands, command_rx) = mpsc::sync_channel(8);
let (ownership_tx, ownership_events) = mpsc::sync_channel(8);
let (ready_tx, ready_rx) = mpsc::sync_channel(1);
thread::Builder::new()
.name("guth-file-clipboard".to_string())
.spawn(move || owner_thread(command_rx, ownership_tx, ready_tx))
.map_err(|error| format!("Could not start file clipboard: {error}"))?;
ready_rx
.recv_timeout(REQUEST_TIMEOUT)
.map_err(|_| "File clipboard did not start".to_string())??;
Ok(Self {
commands,
ownership_events,
})
}
pub fn publish(&self, payload: ClipboardPayload) -> Result<(), String> {
let (result_tx, result_rx) = mpsc::sync_channel(1);
self.commands
.send(OwnerCommand::Publish(payload, result_tx))
.map_err(|_| "File clipboard stopped".to_string())?;
result_rx
.recv_timeout(REQUEST_TIMEOUT)
.map_err(|_| "File clipboard publish timed out".to_string())?
}
pub fn request(&self) -> Result<Receiver<Result<ClipboardRead, String>>, String> {
let (sender, receiver) = mpsc::sync_channel(1);
thread::Builder::new()
.name("guth-file-clipboard-read".to_string())
.spawn(move || {
let _ = sender.send(read_clipboard());
})
.map_err(|error| format!("Could not read file clipboard: {error}"))?;
Ok(receiver)
}
pub fn clear_if_owner(&self, owner: Window) {
let _ = self.commands.try_send(OwnerCommand::Clear(owner));
}
pub fn ownership_lost(&self) -> bool {
let mut lost = false;
while self.ownership_events.try_recv().is_ok() {
lost = true;
}
lost
}
}
impl Drop for DesktopFileClipboard {
fn drop(&mut self) {
let _ = self.commands.try_send(OwnerCommand::Shutdown);
}
}
fn owner_thread(
commands: Receiver<OwnerCommand>,
ownership_tx: SyncSender<()>,
ready: SyncSender<Result<(), String>>,
) {
let result = (|| {
let (connection, screen_number) = x11rb::connect(None).map_err(display_error)?;
let root = connection.setup().roots[screen_number].root;
let window = connection.generate_id().map_err(display_error)?;
connection
.create_window(
x11rb::COPY_FROM_PARENT as u8,
window,
root,
0,
0,
1,
1,
0,
WindowClass::INPUT_ONLY,
0,
&CreateWindowAux::new().event_mask(EventMask::PROPERTY_CHANGE),
)
.map_err(display_error)?
.check()
.map_err(display_error)?;
let atoms = Atoms::new(&connection)
.map_err(display_error)?
.reply()
.map_err(display_error)?;
connection.flush().map_err(display_error)?;
let _ = ready.send(Ok(()));
run_owner(connection, window, atoms, commands, ownership_tx);
Ok::<(), String>(())
})();
if let Err(error) = result {
let _ = ready.send(Err(error));
}
}
fn run_owner(
connection: RustConnection,
window: Window,
atoms: Atoms,
commands: Receiver<OwnerCommand>,
ownership_tx: SyncSender<()>,
) {
let mut owned: Option<OwnedClipboard> = None;
loop {
let mut worked = false;
for _ in 0..8 {
match commands.try_recv() {
Ok(OwnerCommand::Publish(payload, response)) => {
worked = true;
let result = publish_clipboard(&connection, window, &atoms, payload);
if let Ok(snapshot) = result {
owned = Some(snapshot);
let _ = response.send(Ok(()));
} else if let Err(error) = result {
let _ = response.send(Err(error));
}
}
Ok(OwnerCommand::Clear(owner)) => {
worked = true;
clear_selection_if_owner(&connection, window, owner, &atoms, &mut owned);
}
Ok(OwnerCommand::Shutdown) | Err(TryRecvError::Disconnected) => {
clear_owned_selection(&connection, window, &atoms, &mut owned);
let _ = connection.destroy_window(window);
let _ = connection.flush();
return;
}
Err(TryRecvError::Empty) => break,
}
}
for _ in 0..EVENT_LIMIT {
let event = match connection.poll_for_event() {
Ok(Some(event)) => event,
Ok(None) => break,
Err(_) => return,
};
worked = true;
match event {
Event::SelectionRequest(request) => {
let _ = answer_request(&connection, window, &atoms, owned.as_ref(), request);
}
Event::SelectionClear(event)
if event.selection == atoms.CLIPBOARD
&& owned.as_ref().is_some_and(|snapshot| {
timestamp_not_older(event.time, snapshot.timestamp)
}) =>
{
owned = None;
let _ = ownership_tx.try_send(());
}
_ => {}
}
}
if !worked {
thread::sleep(Duration::from_millis(5));
}
}
}
fn publish_clipboard(
connection: &RustConnection,
window: Window,
atoms: &Atoms,
payload: ClipboardPayload,
) -> Result<OwnedClipboard, String> {
let formats = encode_payload(&payload)?;
connection
.set_selection_owner(window, atoms.CLIPBOARD, x11rb::CURRENT_TIME)
.map_err(display_error)?
.check()
.map_err(display_error)?;
let timestamp = server_timestamp(connection, window, atoms.GUTH_CLIPBOARD_TIMESTAMP)?;
let owner = connection
.get_selection_owner(atoms.CLIPBOARD)
.map_err(display_error)?
.reply()
.map_err(display_error)?
.owner;
if owner != window {
return Err("Could not own the desktop clipboard".to_string());
}
Ok(OwnedClipboard {
gnome: formats.gnome,
uri_list: formats.uri_list,
text: formats.text,
timestamp,
})
}
fn answer_request(
connection: &RustConnection,
window: Window,
atoms: &Atoms,
owned: Option<&OwnedClipboard>,
request: SelectionRequestEvent,
) -> Result<(), String> {
if request.owner != window || request.selection != atoms.CLIPBOARD {
return Ok(());
}
let property = if request.property == x11rb::NONE {
request.target
} else {
request.property
};
let current = owned.filter(|snapshot| timestamp_not_older(request.time, snapshot.timestamp));
let result = if request.target == atoms.TARGETS && current.is_some() {
connection
.change_property32(
PropMode::REPLACE,
request.requestor,
property,
AtomEnum::ATOM,
&[
atoms.TARGETS,
atoms.TIMESTAMP,
atoms.SAVE_TARGETS,
atoms.GnomeCopiedFiles,
atoms.TextUriList,
atoms.UTF8_STRING,
],
)
.map_err(|_| ())
.and_then(|cookie| cookie.check().map_err(|_| ()))
} else if request.target == atoms.TIMESTAMP {
current.map_or(Err(()), |snapshot| {
connection
.change_property32(
PropMode::REPLACE,
request.requestor,
property,
AtomEnum::INTEGER,
&[snapshot.timestamp],
)
.map_err(|_| ())?
.check()
.map_err(|_| ())
})
} else if request.target == atoms.SAVE_TARGETS && current.is_some() {
connection
.change_property32(
PropMode::REPLACE,
request.requestor,
property,
AtomEnum::NONE,
&[],
)
.map_err(|_| ())
.and_then(|cookie| cookie.check().map_err(|_| ()))
} else {
let bytes = current.and_then(|snapshot| {
if request.target == atoms.GnomeCopiedFiles {
Some(snapshot.gnome.as_slice())
} else if request.target == atoms.TextUriList {
Some(snapshot.uri_list.as_slice())
} else if request.target == atoms.UTF8_STRING {
Some(snapshot.text.as_slice())
} else {
None
}
});
bytes.map_or(Err(()), |bytes| {
connection
.change_property8(
PropMode::REPLACE,
request.requestor,
property,
request.target,
bytes,
)
.map_err(|_| ())?
.check()
.map_err(|_| ())
})
};
let notify = SelectionNotifyEvent {
response_type: SELECTION_NOTIFY_EVENT,
sequence: 0,
time: request.time,
requestor: request.requestor,
selection: request.selection,
target: request.target,
property: if result.is_ok() {
property
} else {
x11rb::NONE
},
};
connection
.send_event(false, request.requestor, EventMask::NO_EVENT, notify)
.map_err(display_error)?
.check()
.map_err(display_error)?;
connection.flush().map_err(display_error)
}
fn clear_owned_selection(
connection: &RustConnection,
window: Window,
atoms: &Atoms,
owned: &mut Option<OwnedClipboard>,
) {
if owned.is_some()
&& connection
.get_selection_owner(atoms.CLIPBOARD)
.ok()
.and_then(|cookie| cookie.reply().ok())
.is_some_and(|reply| reply.owner == window)
{
let _ = connection.set_selection_owner(x11rb::NONE, atoms.CLIPBOARD, x11rb::CURRENT_TIME);
let _ = connection.flush();
}
*owned = None;
}
fn clear_selection_if_owner(
connection: &RustConnection,
window: Window,
expected_owner: Window,
atoms: &Atoms,
owned: &mut Option<OwnedClipboard>,
) {
let owner = connection
.get_selection_owner(atoms.CLIPBOARD)
.ok()
.and_then(|cookie| cookie.reply().ok())
.map(|reply| reply.owner);
if owner == Some(expected_owner) {
let _ = connection.set_selection_owner(x11rb::NONE, atoms.CLIPBOARD, x11rb::CURRENT_TIME);
let _ = connection.flush();
if expected_owner == window {
*owned = None;
}
}
}
fn read_clipboard() -> Result<ClipboardRead, String> {
let (connection, screen_number) = x11rb::connect(None).map_err(display_error)?;
let root = connection.setup().roots[screen_number].root;
let window = connection.generate_id().map_err(display_error)?;
connection
.create_window(
x11rb::COPY_FROM_PARENT as u8,
window,
root,
0,
0,
1,
1,
0,
WindowClass::INPUT_ONLY,
0,
&CreateWindowAux::new().event_mask(EventMask::PROPERTY_CHANGE),
)
.map_err(display_error)?
.check()
.map_err(display_error)?;
let atoms = Atoms::new(&connection)
.map_err(display_error)?
.reply()
.map_err(display_error)?;
let owner = connection
.get_selection_owner(atoms.CLIPBOARD)
.map_err(display_error)?
.reply()
.map_err(display_error)?
.owner;
if owner == x11rb::NONE {
return Err("Clipboard is empty".to_string());
}
let targets = request_property(&connection, window, &atoms, atoms.TARGETS)?
.ok_or_else(|| "Clipboard does not publish supported targets".to_string())?;
let target_atoms = targets
.value32()
.ok_or_else(|| "Clipboard target list is malformed".to_string())?
.collect::<Vec<_>>();
let (target, gnome) = if target_atoms.contains(&atoms.GnomeCopiedFiles) {
(atoms.GnomeCopiedFiles, true)
} else if target_atoms.contains(&atoms.TextUriList) {
(atoms.TextUriList, false)
} else {
return Err("Clipboard does not contain files".to_string());
};
let property = request_property(&connection, window, &atoms, target)?
.ok_or_else(|| "Clipboard file data was unavailable".to_string())?;
if property.value.len() > CLIPBOARD_BYTES_LIMIT || property.bytes_after != 0 {
return Err("Clipboard file data exceeds the supported size".to_string());
}
let payload = if gnome {
parse_gnome_payload(&property.value)?
} else {
ClipboardPayload {
mode: ClipboardMode::Copy,
paths: parse_uri_list(&property.value)?,
}
};
Ok(ClipboardRead { payload, owner })
}
fn request_property(
connection: &RustConnection,
window: Window,
atoms: &Atoms,
target: Atom,
) -> Result<Option<x11rb::protocol::xproto::GetPropertyReply>, String> {
connection
.delete_property(window, atoms.GUTH_CLIPBOARD_RESPONSE)
.map_err(display_error)?;
connection
.convert_selection(
window,
atoms.CLIPBOARD,
target,
atoms.GUTH_CLIPBOARD_RESPONSE,
x11rb::CURRENT_TIME,
)
.map_err(display_error)?
.check()
.map_err(display_error)?;
connection.flush().map_err(display_error)?;
let deadline = Instant::now() + REQUEST_TIMEOUT;
while Instant::now() < deadline {
if let Some(event) = connection.poll_for_event().map_err(display_error)? {
if let Event::SelectionNotify(event) = event {
if event.requestor == window && event.selection == atoms.CLIPBOARD {
if event.property == x11rb::NONE {
return Ok(None);
}
return connection
.get_property(
true,
window,
event.property,
AtomEnum::ANY,
0,
(CLIPBOARD_BYTES_LIMIT / 4 + 1) as u32,
)
.map_err(display_error)?
.reply()
.map(Some)
.map_err(display_error);
}
}
} else {
thread::sleep(Duration::from_millis(5));
}
}
Err("Clipboard read timed out".to_string())
}
fn encode_payload(payload: &ClipboardPayload) -> Result<ClipboardFormats, String> {
if payload.paths.is_empty() || payload.paths.len() > FILE_LIMIT {
return Err(format!("Clipboard supports 1 to {FILE_LIMIT} files"));
}
let uris = payload
.paths
.iter()
.map(|path| file_uri(path))
.collect::<Result<Vec<_>, _>>()?;
let mut gnome = match payload.mode {
ClipboardMode::Copy => b"copy".to_vec(),
ClipboardMode::Cut => b"cut".to_vec(),
};
let mut uri_list = Vec::new();
let mut text = Vec::new();
for (index, uri) in uris.iter().enumerate() {
gnome.push(b'\n');
gnome.extend_from_slice(uri.as_bytes());
uri_list.extend_from_slice(uri.as_bytes());
uri_list.extend_from_slice(b"\r\n");
if index > 0 {
text.push(b'\n');
}
text.extend_from_slice(uri.as_bytes());
}
if [gnome.len(), uri_list.len(), text.len()]
.into_iter()
.any(|length| length > CLIPBOARD_BYTES_LIMIT)
{
return Err("Clipboard file list exceeds the supported size".to_string());
}
Ok(ClipboardFormats {
gnome,
uri_list,
text,
})
}
fn parse_gnome_payload(bytes: &[u8]) -> Result<ClipboardPayload, String> {
if bytes.is_empty() || bytes.len() > CLIPBOARD_BYTES_LIMIT || bytes.ends_with(b"\n") {
return Err("Malformed GNOME file clipboard".to_string());
}
let mut lines = bytes.split(|byte| *byte == b'\n');
let mode = match lines.next() {
Some(b"copy") => ClipboardMode::Copy,
Some(b"cut") => ClipboardMode::Cut,
_ => return Err("Malformed GNOME file clipboard action".to_string()),
};
let paths = lines.map(file_uri_to_path).collect::<Result<Vec<_>, _>>()?;
validate_paths(paths).map(|paths| ClipboardPayload { mode, paths })
}
fn parse_uri_list(bytes: &[u8]) -> Result<Vec<PathBuf>, String> {
if bytes.is_empty() || bytes.len() > CLIPBOARD_BYTES_LIMIT {
return Err("Malformed file URI clipboard".to_string());
}
let paths = bytes
.split(|byte| *byte == b'\n')
.map(|line| line.strip_suffix(b"\r").unwrap_or(line))
.filter(|line| !line.is_empty() && !line.starts_with(b"#"))
.map(file_uri_to_path)
.collect::<Result<Vec<_>, _>>()?;
validate_paths(paths)
}
fn validate_paths(paths: Vec<PathBuf>) -> Result<Vec<PathBuf>, String> {
if paths.is_empty() || paths.len() > FILE_LIMIT || paths.iter().any(|path| !path.is_absolute())
{
return Err("Clipboard file list is empty or invalid".to_string());
}
Ok(paths)
}
fn file_uri(path: &Path) -> Result<String, String> {
if !path.is_absolute() {
return Err("Clipboard paths must be absolute".to_string());
}
#[cfg(unix)]
let bytes = {
use std::os::unix::ffi::OsStrExt;
path.as_os_str().as_bytes()
};
#[cfg(not(unix))]
let bytes = path
.to_str()
.ok_or_else(|| "Clipboard path is not valid UTF-8".to_string())?
.as_bytes();
let mut uri = String::from("file://");
for byte in bytes {
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~' | b'/') {
uri.push(char::from(*byte));
} else {
const HEX: &[u8; 16] = b"0123456789ABCDEF";
uri.push('%');
uri.push(char::from(HEX[usize::from(byte >> 4)]));
uri.push(char::from(HEX[usize::from(byte & 0x0f)]));
}
}
Ok(uri)
}
fn file_uri_to_path(uri: &[u8]) -> Result<PathBuf, String> {
let rest = uri
.strip_prefix(b"file://")
.ok_or_else(|| "Clipboard contains a non-file URI".to_string())?;
let path = if let Some(path) = rest.strip_prefix(b"localhost/") {
let mut local = Vec::with_capacity(path.len() + 1);
local.push(b'/');
local.extend_from_slice(path);
local
} else if rest.starts_with(b"/") {
rest.to_vec()
} else {
return Err("Clipboard file URI has a remote authority".to_string());
};
if path.contains(&b'?') || path.contains(&b'#') {
return Err("Clipboard file URI contains a query or fragment".to_string());
}
let mut decoded = Vec::with_capacity(path.len());
let mut index = 0;
while index < path.len() {
if path[index] == b'%' {
if index + 2 >= path.len() {
return Err("Clipboard file URI has an invalid escape".to_string());
}
let high = hex_value(path[index + 1])?;
let low = hex_value(path[index + 2])?;
decoded.push((high << 4) | low);
index += 3;
} else {
decoded.push(path[index]);
index += 1;
}
}
if decoded.contains(&0) || decoded.first() != Some(&b'/') {
return Err("Clipboard file URI contains an invalid path".to_string());
}
#[cfg(unix)]
{
use std::os::unix::ffi::OsStringExt;
Ok(PathBuf::from(OsString::from_vec(decoded)))
}
#[cfg(not(unix))]
{
String::from_utf8(decoded)
.map(PathBuf::from)
.map_err(|_| "Clipboard file URI is not UTF-8".to_string())
}
}
fn hex_value(byte: u8) -> Result<u8, String> {
match byte {
b'0'..=b'9' => Ok(byte - b'0'),
b'a'..=b'f' => Ok(byte - b'a' + 10),
b'A'..=b'F' => Ok(byte - b'A' + 10),
_ => Err("Clipboard file URI has an invalid escape".to_string()),
}
}
fn server_timestamp(
connection: &RustConnection,
window: Window,
property: Atom,
) -> Result<u32, String> {
connection
.change_property8(PropMode::REPLACE, window, property, AtomEnum::INTEGER, &[0])
.map_err(display_error)?
.check()
.map_err(display_error)?;
connection.flush().map_err(display_error)?;
loop {
match connection.wait_for_event().map_err(display_error)? {
Event::PropertyNotify(event) if event.window == window && event.atom == property => {
return Ok(event.time);
}
_ => {}
}
}
}
fn timestamp_not_older(candidate: u32, reference: u32) -> bool {
candidate == x11rb::CURRENT_TIME || candidate.wrapping_sub(reference) < (1_u32 << 31)
}
fn display_error(error: impl std::fmt::Display) -> String {
error.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::Command;
use std::sync::{Mutex, OnceLock};
fn desktop_test_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(())).lock().unwrap()
}
#[test]
fn gnome_payload_exactly_preserves_action_and_has_no_trailing_newline() {
let payload = ClipboardPayload {
mode: ClipboardMode::Cut,
paths: vec![PathBuf::from("/tmp/a b"), PathBuf::from("/home/x")],
};
let formats = encode_payload(&payload).unwrap();
assert_eq!(formats.gnome, b"cut\nfile:///tmp/a%20b\nfile:///home/x");
assert_eq!(formats.uri_list, b"file:///tmp/a%20b\r\nfile:///home/x\r\n");
assert_eq!(formats.text, b"file:///tmp/a%20b\nfile:///home/x");
let parsed = parse_gnome_payload(&formats.gnome).unwrap();
assert_eq!(parsed.mode, ClipboardMode::Cut);
assert_eq!(parsed.paths, payload.paths);
}
#[test]
fn uri_list_accepts_comments_crlf_and_terminal_newline() {
let paths = parse_uri_list(b"# source\r\nfile:///tmp/a%20b\r\n\r\n").unwrap();
assert_eq!(paths, vec![PathBuf::from("/tmp/a b")]);
}
#[test]
fn rejects_remote_malformed_and_trailing_gnome_data() {
assert!(file_uri_to_path(b"https://example.com/a").is_err());
assert!(file_uri_to_path(b"file://remote/tmp/a").is_err());
assert!(file_uri_to_path(b"file:///tmp/%GG").is_err());
assert!(parse_gnome_payload(b"copy\nfile:///tmp/a\n").is_err());
}
#[cfg(unix)]
#[test]
fn non_utf8_paths_round_trip() {
use std::os::unix::ffi::OsStringExt;
let path = PathBuf::from(OsString::from_vec(b"/tmp/a\xffb".to_vec()));
let uri = file_uri(&path).unwrap();
assert_eq!(uri, "file:///tmp/a%FFb");
assert_eq!(file_uri_to_path(uri.as_bytes()).unwrap(), path);
}
#[test]
fn desktop_clipboard_round_trips_cut_files() {
let _guard = desktop_test_lock();
let Ok(clipboard) = DesktopFileClipboard::new() else {
return;
};
let payload = ClipboardPayload {
mode: ClipboardMode::Cut,
paths: vec![PathBuf::from("/tmp/clipboard file")],
};
clipboard.publish(payload.clone()).unwrap();
let read = clipboard
.request()
.unwrap()
.recv_timeout(REQUEST_TIMEOUT)
.unwrap()
.unwrap();
assert_eq!(read.payload.mode, ClipboardMode::Cut);
assert_eq!(read.payload.paths, payload.paths);
clipboard.clear_if_owner(read.owner);
}
#[test]
fn external_x11_client_reads_nautilus_format() {
let _guard = desktop_test_lock();
if Command::new("xclip").arg("-version").output().is_err() {
return;
}
let Ok(clipboard) = DesktopFileClipboard::new() else {
return;
};
clipboard
.publish(ClipboardPayload {
mode: ClipboardMode::Copy,
paths: vec![PathBuf::from("/tmp/external file")],
})
.unwrap();
let output = Command::new("xclip")
.args([
"-selection",
"clipboard",
"-target",
"x-special/gnome-copied-files",
"-out",
])
.output()
.unwrap();
assert!(
output.status.success(),
"xclip failed: {}",
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(output.stdout, b"copy\nfile:///tmp/external%20file");
}
}