win_context_menu/ffi.rs
1//! C-compatible FFI layer for `win-context-menu`.
2//!
3//! Enable with the **`ffi`** feature flag. All functions in this module use
4//! C calling conventions and return null pointers on failure. Error details
5//! can be retrieved via [`wcm_last_error`].
6//!
7//! # Threading
8//!
9//! All functions **must** be called from a thread that has been initialized for
10//! COM STA mode (via [`wcm_com_init`]). These functions are **not thread-safe**.
11//!
12//! # Memory ownership
13//!
14//! Pointers returned by `wcm_*` functions are heap-allocated and must be freed
15//! by the corresponding `wcm_free_*` function. Failing to do so will leak
16//! memory.
17
18use std::cell::RefCell;
19use std::ffi::{CStr, CString};
20use std::os::raw::c_char;
21use std::path::PathBuf;
22
23use crate::com::ComGuard;
24use crate::context_menu::ContextMenu;
25use crate::menu_items::MenuItem;
26use crate::shell_item::ShellItems;
27
28thread_local! {
29 static LAST_ERROR: RefCell<Option<CString>> = const { RefCell::new(None) };
30}
31
32fn set_last_error(err: &crate::error::Error) {
33 let msg = CString::new(err.to_string()).unwrap_or_default();
34 LAST_ERROR.with(|e| {
35 *e.borrow_mut() = Some(msg);
36 });
37}
38
39/// Opaque handle for a COM guard.
40pub struct FfiComGuard(ComGuard);
41
42/// Information about a selected context menu item.
43///
44/// Returned by [`wcm_show_context_menu`]. Must be freed with
45/// [`wcm_free_selected`].
46#[repr(C)]
47pub struct FfiSelectedItem {
48 /// The command ID of the selected item.
49 pub command_id: u32,
50 /// The display label (UTF-8, heap-allocated). May be null.
51 pub label: *mut c_char,
52 /// The verb string (UTF-8, heap-allocated). Null if unavailable.
53 pub verb: *mut c_char,
54}
55
56/// Retrieve the last error message from the most recent failed `wcm_*` call
57/// on this thread.
58///
59/// Returns a pointer to a static thread-local C string, or null if no error
60/// has occurred. The pointer is valid until the next `wcm_*` call on the same
61/// thread. **Do not free this pointer.**
62#[no_mangle]
63pub extern "C" fn wcm_last_error() -> *const c_char {
64 LAST_ERROR.with(|e| {
65 e.borrow()
66 .as_ref()
67 .map(|s| s.as_ptr())
68 .unwrap_or(std::ptr::null())
69 })
70}
71
72/// Initialize COM in STA mode.
73///
74/// Must be called once per thread before any other `wcm_*` function.
75/// Returns an opaque handle that must be freed with [`wcm_com_uninit`].
76/// Returns null on failure (call [`wcm_last_error`] for details).
77#[no_mangle]
78pub extern "C" fn wcm_com_init() -> *mut FfiComGuard {
79 match ComGuard::new() {
80 Ok(guard) => Box::into_raw(Box::new(FfiComGuard(guard))),
81 Err(e) => {
82 set_last_error(&e);
83 std::ptr::null_mut()
84 }
85 }
86}
87
88/// Free a COM guard handle returned by [`wcm_com_init`].
89///
90/// # Safety
91///
92/// `guard` must be a pointer returned by [`wcm_com_init`], or null.
93/// Must not be called more than once for the same pointer.
94#[no_mangle]
95pub unsafe extern "C" fn wcm_com_uninit(guard: *mut FfiComGuard) {
96 if !guard.is_null() {
97 // SAFETY: Caller guarantees `guard` was returned by `wcm_com_init`
98 // and has not been freed yet.
99 let _ = unsafe { Box::from_raw(guard) };
100 }
101}
102
103/// Show a context menu for a single file path at the given screen coordinates.
104///
105/// Returns a [`FfiSelectedItem`] pointer if the user selected an item, or null
106/// if the menu was dismissed or an error occurred. On error, call
107/// [`wcm_last_error`] for details.
108///
109/// The returned pointer must be freed with [`wcm_free_selected`]. The
110/// selected command is automatically executed before this function returns.
111///
112/// # Safety
113///
114/// - `path` must be a valid null-terminated UTF-8 C string, or null.
115/// - Must be called from a thread with an active `wcm_com_init` guard.
116#[no_mangle]
117pub unsafe extern "C" fn wcm_show_context_menu(
118 path: *const c_char,
119 x: i32,
120 y: i32,
121) -> *mut FfiSelectedItem {
122 if path.is_null() {
123 return std::ptr::null_mut();
124 }
125
126 // SAFETY: Caller guarantees `path` is a valid null-terminated C string.
127 let path_str = match unsafe { CStr::from_ptr(path) }.to_str() {
128 Ok(s) => s,
129 Err(_) => return std::ptr::null_mut(),
130 };
131
132 let items = match ShellItems::from_path(PathBuf::from(path_str)) {
133 Ok(i) => i,
134 Err(e) => {
135 set_last_error(&e);
136 return std::ptr::null_mut();
137 }
138 };
139
140 let menu = match ContextMenu::new(items) {
141 Ok(m) => m,
142 Err(e) => {
143 set_last_error(&e);
144 return std::ptr::null_mut();
145 }
146 };
147
148 match menu.show_at(x, y) {
149 Ok(Some(selected)) => {
150 let label = CString::new(selected.menu_item().label.as_str())
151 .unwrap_or_default()
152 .into_raw();
153 let verb = selected
154 .menu_item()
155 .command_string
156 .as_deref()
157 .and_then(|v| CString::new(v).ok())
158 .map(|c| c.into_raw())
159 .unwrap_or(std::ptr::null_mut());
160 let command_id = selected.command_id();
161
162 // Execute the command
163 if let Err(e) = selected.execute() {
164 set_last_error(&e);
165 }
166
167 Box::into_raw(Box::new(FfiSelectedItem {
168 command_id,
169 label,
170 verb,
171 }))
172 }
173 Ok(None) => std::ptr::null_mut(),
174 Err(e) => {
175 set_last_error(&e);
176 std::ptr::null_mut()
177 }
178 }
179}
180
181/// Free a [`FfiSelectedItem`] returned by [`wcm_show_context_menu`].
182///
183/// # Safety
184///
185/// `item` must be a pointer returned by [`wcm_show_context_menu`], or null.
186/// Must not be called more than once for the same pointer.
187#[no_mangle]
188pub unsafe extern "C" fn wcm_free_selected(item: *mut FfiSelectedItem) {
189 if !item.is_null() {
190 // SAFETY: Caller guarantees `item` was returned by
191 // `wcm_show_context_menu` and has not been freed yet.
192 let item = unsafe { Box::from_raw(item) };
193 if !item.label.is_null() {
194 // SAFETY: `label` was allocated by `CString::into_raw`.
195 let _ = unsafe { CString::from_raw(item.label) };
196 }
197 if !item.verb.is_null() {
198 // SAFETY: `verb` was allocated by `CString::into_raw`.
199 let _ = unsafe { CString::from_raw(item.verb) };
200 }
201 }
202}
203
204/// Enumerate context menu items as a JSON string.
205///
206/// Returns a heap-allocated null-terminated UTF-8 C string containing a JSON
207/// array, or null on failure (call [`wcm_last_error`] for details). The
208/// returned pointer must be freed with [`wcm_free_string`].
209///
210/// # Safety
211///
212/// - `path` must be a valid null-terminated UTF-8 C string, or null.
213/// - Must be called from a thread with an active `wcm_com_init` guard.
214#[no_mangle]
215pub unsafe extern "C" fn wcm_enumerate_menu(
216 path: *const c_char,
217 extended: bool,
218) -> *mut c_char {
219 if path.is_null() {
220 return std::ptr::null_mut();
221 }
222
223 // SAFETY: Caller guarantees `path` is a valid null-terminated C string.
224 let path_str = match unsafe { CStr::from_ptr(path) }.to_str() {
225 Ok(s) => s,
226 Err(_) => return std::ptr::null_mut(),
227 };
228
229 let items = match ShellItems::from_path(PathBuf::from(path_str)) {
230 Ok(i) => i,
231 Err(e) => {
232 set_last_error(&e);
233 return std::ptr::null_mut();
234 }
235 };
236
237 let menu = match ContextMenu::new(items) {
238 Ok(m) => m.extended(extended),
239 Err(e) => {
240 set_last_error(&e);
241 return std::ptr::null_mut();
242 }
243 };
244
245 match menu.enumerate() {
246 Ok(items) => {
247 let json = menu_items_to_json(&items);
248 match CString::new(json) {
249 Ok(c) => c.into_raw(),
250 Err(_) => std::ptr::null_mut(),
251 }
252 }
253 Err(e) => {
254 set_last_error(&e);
255 std::ptr::null_mut()
256 }
257 }
258}
259
260/// Free a string returned by [`wcm_enumerate_menu`].
261///
262/// # Safety
263///
264/// `s` must be a pointer returned by a `wcm_*` function, or null.
265#[no_mangle]
266pub unsafe extern "C" fn wcm_free_string(s: *mut c_char) {
267 if !s.is_null() {
268 // SAFETY: Caller guarantees `s` was allocated by `CString::into_raw`.
269 let _ = unsafe { CString::from_raw(s) };
270 }
271}
272
273/// Serialize menu items to a JSON array string.
274fn menu_items_to_json(items: &[MenuItem]) -> String {
275 #[derive(serde::Serialize)]
276 struct JsonMenuItem<'a> {
277 id: u32,
278 label: &'a str,
279 #[serde(skip_serializing_if = "Option::is_none")]
280 verb: Option<&'a str>,
281 separator: bool,
282 disabled: bool,
283 #[serde(skip_serializing_if = "Option::is_none")]
284 submenu: Option<Vec<JsonMenuItem<'a>>>,
285 }
286
287 fn to_json_items(items: &[MenuItem]) -> Vec<JsonMenuItem<'_>> {
288 items
289 .iter()
290 .map(|item| JsonMenuItem {
291 id: item.id,
292 label: &item.label,
293 verb: item.command_string.as_deref(),
294 separator: item.is_separator,
295 disabled: item.is_disabled,
296 submenu: item.submenu.as_ref().map(|s| to_json_items(s)),
297 })
298 .collect()
299 }
300
301 serde_json::to_string(&to_json_items(items)).unwrap_or_else(|_| "[]".to_string())
302}