Skip to main content

win_desktop_utils/
shortcuts.rs

1//! Shortcut helpers for Windows `.lnk` and `.url` files.
2
3use std::ffi::OsString;
4use std::path::{Path, PathBuf};
5
6use windows::core::{Interface, PCWSTR};
7use windows::Win32::System::Com::{CoCreateInstance, IPersistFile, CLSCTX_INPROC_SERVER};
8use windows::Win32::UI::Shell::{IShellLinkW, ShellLink};
9
10use crate::error::{Error, Result};
11use crate::win::{
12    join_quoted_args, os_str_contains_nul, path_contains_nul, run_in_sta, to_wide_os, to_wide_str,
13    ComApartment,
14};
15
16/// Icon configuration for a Windows shortcut.
17///
18/// # Examples
19///
20/// ```
21/// let icon = win_desktop_utils::ShortcutIcon::new(r"C:\Windows\notepad.exe", 0);
22///
23/// assert_eq!(icon.index, 0);
24/// assert!(icon.path.ends_with("notepad.exe"));
25/// ```
26#[derive(Clone, Debug, Eq, PartialEq)]
27pub struct ShortcutIcon {
28    /// Path to an icon resource, executable, or DLL.
29    pub path: PathBuf,
30    /// Zero-based icon index inside the resource.
31    pub index: i32,
32}
33
34impl ShortcutIcon {
35    /// Creates icon configuration for a shortcut.
36    pub fn new(path: impl Into<PathBuf>, index: i32) -> Self {
37        Self {
38            path: path.into(),
39            index,
40        }
41    }
42}
43
44/// Options used when creating a Windows `.lnk` shortcut.
45///
46/// # Examples
47///
48/// ```
49/// let options = win_desktop_utils::ShortcutOptions::new()
50///     .argument("--safe-mode")
51///     .working_directory(r"C:\Windows")
52///     .icon(r"C:\Windows\notepad.exe", 0)
53///     .description("Open a tool");
54///
55/// assert_eq!(options.arguments.len(), 1);
56/// assert!(options.working_directory.is_some());
57/// assert!(options.icon.is_some());
58/// assert_eq!(options.description.as_deref(), Some("Open a tool"));
59/// ```
60#[derive(Clone, Debug, Default, Eq, PartialEq)]
61pub struct ShortcutOptions {
62    /// Command-line arguments stored in the shortcut.
63    pub arguments: Vec<OsString>,
64    /// Optional working directory for the shortcut target.
65    pub working_directory: Option<PathBuf>,
66    /// Optional icon resource for the shortcut.
67    pub icon: Option<ShortcutIcon>,
68    /// Optional user-facing shortcut description.
69    pub description: Option<String>,
70}
71
72impl ShortcutOptions {
73    /// Creates empty shortcut options.
74    pub fn new() -> Self {
75        Self::default()
76    }
77
78    /// Replaces the shortcut argument list.
79    pub fn arguments<I, S>(mut self, arguments: I) -> Self
80    where
81        I: IntoIterator<Item = S>,
82        S: Into<OsString>,
83    {
84        self.arguments = arguments.into_iter().map(Into::into).collect();
85        self
86    }
87
88    /// Appends one shortcut argument.
89    pub fn argument(mut self, argument: impl Into<OsString>) -> Self {
90        self.arguments.push(argument.into());
91        self
92    }
93
94    /// Sets the shortcut working directory.
95    pub fn working_directory(mut self, path: impl Into<PathBuf>) -> Self {
96        self.working_directory = Some(path.into());
97        self
98    }
99
100    /// Sets the shortcut icon resource.
101    pub fn icon(mut self, path: impl Into<PathBuf>, index: i32) -> Self {
102        self.icon = Some(ShortcutIcon::new(path, index));
103        self
104    }
105
106    /// Sets the shortcut description.
107    pub fn description(mut self, description: impl Into<String>) -> Self {
108        self.description = Some(description.into());
109        self
110    }
111}
112
113#[derive(Clone, Debug)]
114struct ShortcutRequest {
115    shortcut_path: PathBuf,
116    target_path: PathBuf,
117    options: ShortcutOptions,
118}
119
120fn join_args_for_shortcut(args: &[OsString]) -> String {
121    join_quoted_args(args)
122}
123
124fn has_extension(path: &Path, expected: &str) -> bool {
125    path.extension()
126        .map(|extension| extension.to_string_lossy().eq_ignore_ascii_case(expected))
127        .unwrap_or(false)
128}
129
130fn validate_output_path(path: &Path, extension: &str, label: &'static str) -> Result<()> {
131    if path.as_os_str().is_empty() {
132        return Err(Error::InvalidInput(label));
133    }
134
135    if path_contains_nul(path) {
136        return Err(Error::InvalidInput(
137            "shortcut path cannot contain NUL bytes",
138        ));
139    }
140
141    if !path.is_absolute() {
142        return Err(Error::PathNotAbsolute);
143    }
144
145    if !has_extension(path, extension) {
146        return Err(Error::InvalidInput(match extension {
147            "lnk" => "shortcut path must use .lnk extension",
148            "url" => "shortcut path must use .url extension",
149            _ => "shortcut path has an unsupported extension",
150        }));
151    }
152
153    let parent = path
154        .parent()
155        .filter(|parent| !parent.as_os_str().is_empty())
156        .ok_or(Error::InvalidInput(
157            "shortcut path must have a parent directory",
158        ))?;
159
160    if !parent.exists() {
161        return Err(Error::PathDoesNotExist);
162    }
163
164    Ok(())
165}
166
167fn validate_existing_absolute_path(
168    path: &Path,
169    empty_message: &'static str,
170    nul_message: &'static str,
171) -> Result<()> {
172    if path.as_os_str().is_empty() {
173        return Err(Error::InvalidInput(empty_message));
174    }
175
176    if path_contains_nul(path) {
177        return Err(Error::InvalidInput(nul_message));
178    }
179
180    if !path.is_absolute() {
181        return Err(Error::PathNotAbsolute);
182    }
183
184    if !path.exists() {
185        return Err(Error::PathDoesNotExist);
186    }
187
188    Ok(())
189}
190
191fn validate_options(options: &ShortcutOptions) -> Result<()> {
192    if options
193        .arguments
194        .iter()
195        .any(|arg| os_str_contains_nul(arg.as_os_str()))
196    {
197        return Err(Error::InvalidInput(
198            "shortcut arguments cannot contain NUL bytes",
199        ));
200    }
201
202    if let Some(description) = &options.description {
203        if description.contains('\0') {
204            return Err(Error::InvalidInput(
205                "shortcut description cannot contain NUL bytes",
206            ));
207        }
208    }
209
210    if let Some(working_directory) = &options.working_directory {
211        validate_existing_absolute_path(
212            working_directory,
213            "working_directory cannot be empty",
214            "working_directory cannot contain NUL bytes",
215        )?;
216
217        if !working_directory.is_dir() {
218            return Err(Error::InvalidInput("working_directory must be a directory"));
219        }
220    }
221
222    if let Some(icon) = &options.icon {
223        validate_existing_absolute_path(
224            &icon.path,
225            "icon path cannot be empty",
226            "icon path cannot contain NUL bytes",
227        )?;
228    }
229
230    Ok(())
231}
232
233fn validate_url(url: &str) -> Result<&str> {
234    let trimmed = url.trim();
235
236    if trimmed.is_empty() {
237        return Err(Error::InvalidInput("url cannot be empty"));
238    }
239
240    if trimmed.contains('\0') {
241        return Err(Error::InvalidInput("url cannot contain NUL bytes"));
242    }
243
244    if trimmed.contains('\r') || trimmed.contains('\n') {
245        return Err(Error::InvalidInput("url cannot contain line breaks"));
246    }
247
248    Ok(trimmed)
249}
250
251fn create_shortcut_in_sta(request: ShortcutRequest) -> Result<()> {
252    let _com = ComApartment::initialize_sta("CoInitializeEx")?;
253    let link: IShellLinkW = unsafe { CoCreateInstance(&ShellLink, None, CLSCTX_INPROC_SERVER) }
254        .map_err(|err| Error::WindowsApi {
255            context: "CoCreateInstance(ShellLink)",
256            code: err.code().0,
257        })?;
258
259    let target_w = to_wide_os(request.target_path.as_os_str());
260    unsafe { link.SetPath(PCWSTR(target_w.as_ptr())) }.map_err(|err| Error::WindowsApi {
261        context: "IShellLinkW::SetPath",
262        code: err.code().0,
263    })?;
264
265    if !request.options.arguments.is_empty() {
266        let arguments = join_args_for_shortcut(&request.options.arguments);
267        let arguments_w = to_wide_str(&arguments);
268        unsafe { link.SetArguments(PCWSTR(arguments_w.as_ptr())) }.map_err(|err| {
269            Error::WindowsApi {
270                context: "IShellLinkW::SetArguments",
271                code: err.code().0,
272            }
273        })?;
274    }
275
276    if let Some(working_directory) = &request.options.working_directory {
277        let working_directory_w = to_wide_os(working_directory.as_os_str());
278        unsafe { link.SetWorkingDirectory(PCWSTR(working_directory_w.as_ptr())) }.map_err(
279            |err| Error::WindowsApi {
280                context: "IShellLinkW::SetWorkingDirectory",
281                code: err.code().0,
282            },
283        )?;
284    }
285
286    if let Some(description) = &request.options.description {
287        let description_w = to_wide_str(description);
288        unsafe { link.SetDescription(PCWSTR(description_w.as_ptr())) }.map_err(|err| {
289            Error::WindowsApi {
290                context: "IShellLinkW::SetDescription",
291                code: err.code().0,
292            }
293        })?;
294    }
295
296    if let Some(icon) = &request.options.icon {
297        let icon_w = to_wide_os(icon.path.as_os_str());
298        unsafe { link.SetIconLocation(PCWSTR(icon_w.as_ptr()), icon.index) }.map_err(|err| {
299            Error::WindowsApi {
300                context: "IShellLinkW::SetIconLocation",
301                code: err.code().0,
302            }
303        })?;
304    }
305
306    let persist: IPersistFile = link.cast().map_err(|err| Error::WindowsApi {
307        context: "IShellLinkW::cast(IPersistFile)",
308        code: err.code().0,
309    })?;
310    let shortcut_w = to_wide_os(request.shortcut_path.as_os_str());
311
312    unsafe { persist.Save(PCWSTR(shortcut_w.as_ptr()), true) }.map_err(|err| Error::WindowsApi {
313        context: "IPersistFile::Save",
314        code: err.code().0,
315    })
316}
317
318fn run_in_shortcut_sta<T, F>(work: F) -> Result<T>
319where
320    T: Send + 'static,
321    F: FnOnce() -> Result<T> + Send + 'static,
322{
323    run_in_sta("shortcut STA worker thread panicked", work)
324}
325
326/// Creates or overwrites a Windows `.lnk` shortcut.
327///
328/// `shortcut_path` must be an absolute `.lnk` path whose parent directory exists.
329/// `target_path` must be an existing absolute path. Use [`ShortcutOptions`] to set
330/// arguments, a working directory, an icon, or a description.
331///
332/// # Errors
333///
334/// Returns [`Error::InvalidInput`] for empty paths, NUL bytes, unsupported extensions,
335/// invalid options, or malformed text fields.
336/// Returns [`Error::PathNotAbsolute`] if a required path is relative.
337/// Returns [`Error::PathDoesNotExist`] if the target path or output parent directory
338/// does not exist.
339/// Returns [`Error::WindowsApi`] if COM or Shell Link APIs report failure.
340///
341/// # Examples
342///
343/// ```no_run
344/// let shortcut = std::env::current_dir()?.join("notepad.lnk");
345/// let options = win_desktop_utils::ShortcutOptions::new()
346///     .description("Open Notepad");
347///
348/// win_desktop_utils::create_shortcut(
349///     &shortcut,
350///     r"C:\Windows\notepad.exe",
351///     &options,
352/// )?;
353/// # Ok::<(), Box<dyn std::error::Error>>(())
354/// ```
355pub fn create_shortcut(
356    shortcut_path: impl AsRef<Path>,
357    target_path: impl AsRef<Path>,
358    options: &ShortcutOptions,
359) -> Result<()> {
360    let shortcut_path = shortcut_path.as_ref();
361    let target_path = target_path.as_ref();
362
363    validate_output_path(shortcut_path, "lnk", "shortcut path cannot be empty")?;
364    validate_existing_absolute_path(
365        target_path,
366        "target path cannot be empty",
367        "target path cannot contain NUL bytes",
368    )?;
369    validate_options(options)?;
370
371    let request = ShortcutRequest {
372        shortcut_path: shortcut_path.to_path_buf(),
373        target_path: target_path.to_path_buf(),
374        options: options.clone(),
375    };
376
377    run_in_shortcut_sta(move || create_shortcut_in_sta(request))
378}
379
380/// Creates or overwrites an Internet Shortcut `.url` file.
381///
382/// `shortcut_path` must be an absolute `.url` path whose parent directory exists.
383/// Surrounding whitespace is trimmed from `url`.
384///
385/// # Errors
386///
387/// Returns [`Error::InvalidInput`] for empty paths, malformed URLs, NUL bytes,
388/// line breaks in the URL, or unsupported extensions.
389/// Returns [`Error::PathNotAbsolute`] if `shortcut_path` is relative.
390/// Returns [`Error::PathDoesNotExist`] if the output parent directory does not exist.
391/// Returns [`Error::Io`] if writing the shortcut file fails.
392///
393/// # Examples
394///
395/// ```
396/// let shortcut = std::env::temp_dir().join(format!(
397///     "win-desktop-utils-docs-{}.url",
398///     std::process::id()
399/// ));
400///
401/// win_desktop_utils::create_url_shortcut(
402///     &shortcut,
403///     "https://docs.rs/win-desktop-utils",
404/// )?;
405/// std::fs::remove_file(shortcut)?;
406/// # Ok::<(), Box<dyn std::error::Error>>(())
407/// ```
408pub fn create_url_shortcut(shortcut_path: impl AsRef<Path>, url: &str) -> Result<()> {
409    let shortcut_path = shortcut_path.as_ref();
410    let url = validate_url(url)?;
411
412    validate_output_path(shortcut_path, "url", "shortcut path cannot be empty")?;
413
414    let body = format!("[InternetShortcut]\r\nURL={url}\r\n");
415    std::fs::write(shortcut_path, body)?;
416
417    Ok(())
418}
419
420#[cfg(test)]
421mod tests {
422    use super::{
423        create_url_shortcut, join_args_for_shortcut, validate_output_path, validate_url,
424        ShortcutOptions,
425    };
426    use std::ffi::OsString;
427
428    #[test]
429    fn shortcut_options_builder_sets_values() {
430        let options = ShortcutOptions::new()
431            .argument("--help")
432            .working_directory(r"C:\Windows")
433            .icon(r"C:\Windows\notepad.exe", 0)
434            .description("Demo shortcut");
435
436        assert_eq!(options.arguments, [OsString::from("--help")]);
437        assert_eq!(options.description.as_deref(), Some("Demo shortcut"));
438        assert!(options.working_directory.is_some());
439        assert!(options.icon.is_some());
440    }
441
442    #[test]
443    fn join_args_quotes_each_argument() {
444        let args = [OsString::from("alpha"), OsString::from("two words")];
445        assert_eq!(join_args_for_shortcut(&args), "\"alpha\" \"two words\"");
446    }
447
448    #[test]
449    fn validate_url_trims_surrounding_whitespace() {
450        assert_eq!(
451            validate_url("  https://example.com/docs  ").unwrap(),
452            "https://example.com/docs"
453        );
454    }
455
456    #[test]
457    fn validate_url_rejects_line_breaks() {
458        let result = validate_url("https://example.com/\r\nIconFile=bad.ico");
459        assert!(matches!(
460            result,
461            Err(crate::Error::InvalidInput("url cannot contain line breaks"))
462        ));
463    }
464
465    #[test]
466    fn validate_output_path_rejects_relative_paths() {
467        let result = validate_output_path(
468            std::path::Path::new("demo.lnk"),
469            "lnk",
470            "shortcut path cannot be empty",
471        );
472        assert!(matches!(result, Err(crate::Error::PathNotAbsolute)));
473    }
474
475    #[test]
476    fn validate_output_path_rejects_wrong_extension() {
477        let path = std::env::temp_dir().join("demo.txt");
478        let result = validate_output_path(&path, "lnk", "shortcut path cannot be empty");
479        assert!(matches!(
480            result,
481            Err(crate::Error::InvalidInput(
482                "shortcut path must use .lnk extension"
483            ))
484        ));
485    }
486
487    #[test]
488    fn create_url_shortcut_writes_url_file() {
489        let path = std::env::temp_dir().join(format!(
490            "win-desktop-utils-url-shortcut-test-{}.url",
491            std::process::id()
492        ));
493        let _ = std::fs::remove_file(&path);
494
495        create_url_shortcut(&path, " https://example.com/docs ").unwrap();
496
497        let body = std::fs::read_to_string(&path).unwrap();
498        assert_eq!(
499            body,
500            "[InternetShortcut]\r\nURL=https://example.com/docs\r\n"
501        );
502
503        std::fs::remove_file(path).unwrap();
504    }
505}