showfile/
lib.rs

1//! # showfile
2//!
3//! A simple API to show the location of a file in the local file manager (Explorer, Finder, etc.).
4//! Supported platforms are Windows, macOS, Linux.
5//!
6//! ## Usage
7//!
8//! ```no_run
9//! showfile::show_path_in_file_manager("C:\\Users\\Alice\\hello.txt");
10//! showfile::show_path_in_file_manager("/Users/Bob/hello.txt");
11//! showfile::show_uri_in_file_manager("file:///home/charlie/hello.txt");
12//! ```
13//!
14//! # Feature Flags
15//!
16//! On Linux, D-Bus is used to invoke the file manager. The D-Bus crate in use can be selected with
17//! one of these flags:
18//!
19//! - [`rustbus`](https://github.com/KillingSpark/rustbus) (default)
20//! - [`zbus`](https://dbus2.github.io/zbus/)
21//! - [`gio`](https://gtk-rs.org/gtk-rs-core/stable/latest/docs/gio/)
22//!
23//! One of these flags must be specified to build the project. These flags do nothing on Windows
24//! and macOS. If only targeting those platforms, it can be left at the default.
25//!
26//! ## Details
27//!
28//! This crate is a simple wrapper around these system functions:
29//!
30//! - Windows: [`SHOpenFolderAndSelectItems`](https://learn.microsoft.com/en-us/windows/win32/api/shlobj_core/nf-shlobj_core-shopenfolderandselectitems)
31//! - macOS: [`NSWorkspace activateFileViewerSelectingURLs:`](https://developer.apple.com/documentation/appkit/nsworkspace/1524549-activatefileviewerselecting)
32//! - Linux: [`org.freedesktop.FileManager1.ShowItems`](https://www.freedesktop.org/wiki/Specifications/file-manager-interface/)
33
34use std::path::Path;
35
36#[cfg(not(any(
37    all(feature = "rustbus", not(feature = "zbus"), not(feature = "gio")),
38    all(not(feature = "rustbus"), feature = "zbus", not(feature = "gio")),
39    all(not(feature = "rustbus"), not(feature = "zbus"), feature = "gio")
40)))]
41compile_error!("only one of `rustbus`, `zbus`, or `gio` must be selected");
42
43#[cfg_attr(target_os = "macos", link(name = "AppKit", kind = "framework"))]
44extern "C" {}
45
46#[cfg(target_os = "macos")]
47use objc::{class, msg_send, sel, sel_impl};
48#[cfg(target_os = "macos")]
49#[allow(non_camel_case_types)]
50type id = *mut objc::runtime::Object;
51#[cfg(target_os = "macos")]
52#[allow(non_upper_case_globals)]
53const nil: id = std::ptr::null_mut();
54
55#[cfg(target_os = "macos")]
56unsafe fn show_nsurl_in_file_manager(nsurl: id) {
57    let ws: id = msg_send![class!(NSWorkspace), sharedWorkspace];
58    let urls: id = msg_send![class!(NSArray), arrayWithObject:nsurl];
59    let urls: id = msg_send![urls, autorelease];
60    let _: () = msg_send![ws, activateFileViewerSelectingURLs:urls];
61}
62
63#[cfg(all(not(target_os = "macos"), not(windows), feature = "gio"))]
64unsafe fn gdbus_show_uri_in_file_manager(uri: *const std::ffi::c_char) {
65    use std::ptr::{null, null_mut};
66
67    let bus = gio_sys::g_bus_get_sync(gio_sys::G_BUS_TYPE_SESSION, null_mut(), null_mut());
68    if bus.is_null() {
69        return;
70    }
71    let uris = [uri, null()];
72    let args = glib_sys::g_variant_new(
73        b"(^ass)\0".as_ptr() as *const _,
74        uris.as_ptr(),
75        b"\0".as_ptr(),
76    );
77    let ret = gio_sys::g_dbus_connection_call_sync(
78        bus,
79        b"org.freedesktop.FileManager1\0".as_ptr() as *const _,
80        b"/org/freedesktop/FileManager1\0".as_ptr() as *const _,
81        b"org.freedesktop.FileManager1\0".as_ptr() as *const _,
82        b"ShowItems\0".as_ptr() as *const _,
83        args,
84        null(),
85        0,
86        -1,
87        null_mut(),
88        null_mut(),
89    );
90    if !ret.is_null() {
91        glib_sys::g_variant_unref(ret);
92    }
93    gobject_sys::g_object_unref(bus as *mut _);
94}
95
96/// Tries to show `path` in a file manager.
97///
98/// The path shold be an absolute path. Support for relative paths is platform-specific and may
99/// fail silently or cause the file manager to display an error message.
100///
101/// This function may do nothing at all depending on the current system. The result is
102/// platform-specific if the path does not exist, is inaccessible, or if the file manager is
103/// unavailable. The file manager may display an error message if a non-existent path is provided.
104///
105/// This function can block, so take care when calling from GUI programs. In those cases it should
106/// be called on another thread, or called using your runtime's API to wrap blocking calls such as
107/// [`tokio::task::spawn_blocking`](https://docs.rs/tokio/latest/tokio/task/fn.spawn_blocking.html)
108/// or [`gio::spawn_blocking`](https://gtk-rs.org/gtk-rs-core/stable/latest/docs/gio/fn.spawn_blocking.html).
109pub fn show_path_in_file_manager(path: impl AsRef<Path>) {
110    #[cfg(windows)]
111    unsafe {
112        use std::{
113            borrow::Cow,
114            path::{Component, Prefix},
115        };
116        use windows::{
117            core::{Result, HSTRING},
118            Win32::{System::Com::*, UI::Shell::*},
119        };
120
121        struct ComHandle(());
122        impl ComHandle {
123            fn new() -> Result<Self> {
124                unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED)? };
125                Ok(Self(()))
126            }
127        }
128        impl Drop for ComHandle {
129            fn drop(&mut self) {
130                unsafe {
131                    CoUninitialize();
132                }
133            }
134        }
135        std::thread_local! { static COM_HANDLE: Result<ComHandle> = ComHandle::new(); }
136        COM_HANDLE.with(|r| r.as_ref().map(|_| ()).unwrap());
137
138        let path = Cow::Borrowed(path.as_ref());
139
140        // SHParseDisplayName seems to fail with UNC paths, so convert them back
141        let mut components = path.components();
142        let path = match components.next() {
143            Some(Component::Prefix(prefix)) => match prefix.kind() {
144                Prefix::VerbatimUNC(server, share) => Cow::Owned(
145                    Path::new("\\\\").join(Path::new(server).join(share).join(components)),
146                ),
147                Prefix::VerbatimDisk(disk) => {
148                    let prefix = [disk, b':', b'\\'];
149                    let prefix = std::ffi::OsStr::from_encoded_bytes_unchecked(&prefix);
150                    Cow::Owned(Path::new(prefix).join(components))
151                }
152                Prefix::Verbatim(prefix) => {
153                    Cow::Owned(Path::new("\\\\").join(Path::new(prefix).join(components)))
154                }
155                _ => path,
156            },
157            _ => path,
158        };
159        let mut idlist = std::ptr::null_mut();
160        let res = SHParseDisplayName(
161            &HSTRING::from(path.as_os_str()),
162            None::<&IBindCtx>,
163            &mut idlist,
164            0,
165            None,
166        );
167        if res.is_ok() && !idlist.is_null() {
168            let _ = SHOpenFolderAndSelectItems(idlist, None, 0);
169            CoTaskMemFree(Some(idlist as *const _));
170        }
171    }
172
173    #[cfg(target_os = "macos")]
174    unsafe {
175        let path = path.as_ref().as_os_str().as_encoded_bytes();
176        let s: id = msg_send![class!(NSString), alloc];
177        let s: id = msg_send![
178            s,
179            initWithBytes:path.as_ptr()
180            length:path.len()
181            encoding:4 as id
182        ];
183        let s: id = msg_send![s, autorelease];
184        let url: id = msg_send![class!(NSURL), fileURLWithPath:s];
185        if url != nil {
186            show_nsurl_in_file_manager(url);
187        }
188    }
189
190    #[cfg(all(not(windows), not(target_os = "macos"), not(feature = "gio")))]
191    {
192        use std::path::Component;
193
194        let path = path.as_ref();
195        if path.is_relative() {
196            return;
197        }
198        let mut uri = String::with_capacity(path.as_os_str().as_encoded_bytes().len() + 7);
199        uri.push_str("file://");
200        let mut components = path.components().peekable();
201        if components.peek().is_none() {
202            return;
203        }
204        while let Some(component) = components.next() {
205            match component {
206                Component::RootDir => uri.push('/'),
207                Component::Prefix(_) => return,
208                _ => {
209                    let component = component.as_os_str().as_encoded_bytes();
210                    uri.push_str(&urlencoding::encode_binary(component));
211                    if components.peek().is_some() {
212                        uri.push('/');
213                    }
214                }
215            }
216        }
217        show_uri_in_file_manager(&uri);
218    }
219
220    #[cfg(all(not(windows), not(target_os = "macos"), feature = "gio"))]
221    unsafe {
222        let path = path.as_ref().as_os_str().as_encoded_bytes().to_vec();
223        let path = std::ffi::CString::new(path).unwrap_or_else(|e| {
224            let pos = e.nul_position();
225            let mut uri = e.into_vec();
226            uri.truncate(pos);
227            std::ffi::CString::new(uri).unwrap()
228        });
229        let file = gio_sys::g_file_new_for_path(path.as_ptr());
230        let uri = gio_sys::g_file_get_uri(file);
231        if !uri.is_null() {
232            if uri.read() != 0 {
233                gdbus_show_uri_in_file_manager(uri);
234            }
235            glib_sys::g_free(uri as *mut _);
236        }
237        gobject_sys::g_object_unref(file as *mut _);
238    }
239}
240
241/// Tries to show `uri` in a file manager.
242///
243/// URIs with the `file://` scheme should work on all platforms. On some platforms, the file
244/// manager may be able to browse network URIs such as with the ftp://` or `smb://` schemes. The
245/// file manager may fail silently or display an error message if given a non-supported URI scheme.
246///
247/// This function may do nothing at all depending on the current system. The result is
248/// platform-specific if the path does not exist, is inaccessible, or if the file manager is
249/// unavailable. The file manager may display an error message if a non-existent path is provided.
250///
251/// This function can block, so take care when calling from GUI programs. In those cases it should
252/// be called on another thread, or called using your runtime's API to wrap blocking calls such as
253/// [`tokio::task::spawn_blocking`](https://docs.rs/tokio/latest/tokio/task/fn.spawn_blocking.html)
254/// or [`gio::spawn_blocking`](https://gtk-rs.org/gtk-rs-core/stable/latest/docs/gio/fn.spawn_blocking.html).
255pub fn show_uri_in_file_manager(uri: impl AsRef<str>) {
256    #[cfg(windows)]
257    show_path_in_file_manager(Path::new(uri.as_ref()));
258
259    #[cfg(target_os = "macos")]
260    unsafe {
261        let uri = uri.as_ref();
262        let s: id = msg_send![class!(NSString), alloc];
263        let s: id = msg_send![
264            s,
265            initWithBytes:uri.as_ptr()
266            length:uri.len()
267            encoding:4 as id
268        ];
269        let s: id = msg_send![s, autorelease];
270        let url: id = msg_send![class!(NSURL), URLWithString:s];
271        if url != nil {
272            show_nsurl_in_file_manager(url);
273        }
274    }
275
276    #[cfg(all(not(target_os = "macos"), not(windows), feature = "rustbus"))]
277    {
278        if let Ok(mut bus) = rustbus::RpcConn::session_conn(rustbus::connection::Timeout::Infinite)
279        {
280            let uri = uri.as_ref();
281            let mut msg = rustbus::MessageBuilder::new()
282                .call("ShowItems")
283                .on("/org/freedesktop/FileManager1")
284                .with_interface("org.freedesktop.FileManager1")
285                .at("org.freedesktop.FileManager1")
286                .build();
287            msg.body.push_param([uri].as_slice()).unwrap();
288            msg.body.push_param("").unwrap();
289            if let Ok(ctx) = bus.send_message(&mut msg) {
290                let _ = ctx.write_all();
291            }
292            drop(bus);
293        }
294    }
295
296    #[cfg(all(not(target_os = "macos"), not(windows), feature = "zbus"))]
297    {
298        let uri = uri.as_ref();
299        if let Ok(bus) = zbus::blocking::Connection::session() {
300            let _ = bus.call_method(
301                Some("org.freedesktop.FileManager1"),
302                "/org/freedesktop/FileManager1",
303                Some("org.freedesktop.FileManager1"),
304                "ShowItems",
305                &([uri].as_slice(), ""),
306            );
307        }
308    }
309
310    #[cfg(all(not(target_os = "macos"), not(windows), feature = "gio"))]
311    unsafe {
312        let uri = uri.as_ref();
313        let uri = std::ffi::CString::new(uri).unwrap_or_else(|e| {
314            let pos = e.nul_position();
315            let mut uri = e.into_vec();
316            uri.truncate(pos);
317            std::ffi::CString::new(uri).unwrap()
318        });
319        gdbus_show_uri_in_file_manager(uri.as_ptr());
320    }
321}