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    /// Selects an icon resource path and zero-based icon index.
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 options with no arguments, working directory, icon, or description.
74    pub fn new() -> Self {
75        Self::default()
76    }
77
78    /// Replaces the command-line arguments stored in the shortcut.
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 command-line argument stored in the shortcut.
89    pub fn argument(mut self, argument: impl Into<OsString>) -> Self {
90        self.arguments.push(argument.into());
91        self
92    }
93
94    /// Sets the working directory used when the shortcut target starts.
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 icon resource used by Explorer for the shortcut.
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 shown by Explorer.
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    if !parent.is_dir() {
165        return Err(Error::InvalidInput(
166            "shortcut path parent must be a directory",
167        ));
168    }
169
170    Ok(())
171}
172
173fn validate_existing_absolute_path(
174    path: &Path,
175    empty_message: &'static str,
176    nul_message: &'static str,
177) -> Result<()> {
178    if path.as_os_str().is_empty() {
179        return Err(Error::InvalidInput(empty_message));
180    }
181
182    if path_contains_nul(path) {
183        return Err(Error::InvalidInput(nul_message));
184    }
185
186    if !path.is_absolute() {
187        return Err(Error::PathNotAbsolute);
188    }
189
190    if !path.exists() {
191        return Err(Error::PathDoesNotExist);
192    }
193
194    Ok(())
195}
196
197fn validate_options(options: &ShortcutOptions) -> Result<()> {
198    if options
199        .arguments
200        .iter()
201        .any(|arg| os_str_contains_nul(arg.as_os_str()))
202    {
203        return Err(Error::InvalidInput(
204            "shortcut arguments cannot contain NUL bytes",
205        ));
206    }
207
208    if let Some(description) = &options.description {
209        if description.contains('\0') {
210            return Err(Error::InvalidInput(
211                "shortcut description cannot contain NUL bytes",
212            ));
213        }
214    }
215
216    if let Some(working_directory) = &options.working_directory {
217        validate_existing_absolute_path(
218            working_directory,
219            "working_directory cannot be empty",
220            "working_directory cannot contain NUL bytes",
221        )?;
222
223        if !working_directory.is_dir() {
224            return Err(Error::InvalidInput("working_directory must be a directory"));
225        }
226    }
227
228    if let Some(icon) = &options.icon {
229        validate_existing_absolute_path(
230            &icon.path,
231            "icon path cannot be empty",
232            "icon path cannot contain NUL bytes",
233        )?;
234    }
235
236    Ok(())
237}
238
239fn validate_url(url: &str) -> Result<&str> {
240    let trimmed = url.trim();
241
242    if trimmed.is_empty() {
243        return Err(Error::InvalidInput("url cannot be empty"));
244    }
245
246    if trimmed.contains('\0') {
247        return Err(Error::InvalidInput("url cannot contain NUL bytes"));
248    }
249
250    if trimmed.contains('\r') || trimmed.contains('\n') {
251        return Err(Error::InvalidInput("url cannot contain line breaks"));
252    }
253
254    Ok(trimmed)
255}
256
257fn create_shortcut_in_sta(request: ShortcutRequest) -> Result<()> {
258    let _com = ComApartment::initialize_sta("CoInitializeEx")?;
259    let link: IShellLinkW = unsafe { CoCreateInstance(&ShellLink, None, CLSCTX_INPROC_SERVER) }
260        .map_err(|err| Error::WindowsApi {
261            context: "CoCreateInstance(ShellLink)",
262            code: err.code().0,
263        })?;
264
265    let target_w = to_wide_os(request.target_path.as_os_str());
266    unsafe { link.SetPath(PCWSTR(target_w.as_ptr())) }.map_err(|err| Error::WindowsApi {
267        context: "IShellLinkW::SetPath",
268        code: err.code().0,
269    })?;
270
271    if !request.options.arguments.is_empty() {
272        let arguments = join_args_for_shortcut(&request.options.arguments);
273        let arguments_w = to_wide_str(&arguments);
274        unsafe { link.SetArguments(PCWSTR(arguments_w.as_ptr())) }.map_err(|err| {
275            Error::WindowsApi {
276                context: "IShellLinkW::SetArguments",
277                code: err.code().0,
278            }
279        })?;
280    }
281
282    if let Some(working_directory) = &request.options.working_directory {
283        let working_directory_w = to_wide_os(working_directory.as_os_str());
284        unsafe { link.SetWorkingDirectory(PCWSTR(working_directory_w.as_ptr())) }.map_err(
285            |err| Error::WindowsApi {
286                context: "IShellLinkW::SetWorkingDirectory",
287                code: err.code().0,
288            },
289        )?;
290    }
291
292    if let Some(description) = &request.options.description {
293        let description_w = to_wide_str(description);
294        unsafe { link.SetDescription(PCWSTR(description_w.as_ptr())) }.map_err(|err| {
295            Error::WindowsApi {
296                context: "IShellLinkW::SetDescription",
297                code: err.code().0,
298            }
299        })?;
300    }
301
302    if let Some(icon) = &request.options.icon {
303        let icon_w = to_wide_os(icon.path.as_os_str());
304        unsafe { link.SetIconLocation(PCWSTR(icon_w.as_ptr()), icon.index) }.map_err(|err| {
305            Error::WindowsApi {
306                context: "IShellLinkW::SetIconLocation",
307                code: err.code().0,
308            }
309        })?;
310    }
311
312    let persist: IPersistFile = link.cast().map_err(|err| Error::WindowsApi {
313        context: "IShellLinkW::cast(IPersistFile)",
314        code: err.code().0,
315    })?;
316    let shortcut_w = to_wide_os(request.shortcut_path.as_os_str());
317
318    unsafe { persist.Save(PCWSTR(shortcut_w.as_ptr()), true) }.map_err(|err| Error::WindowsApi {
319        context: "IPersistFile::Save",
320        code: err.code().0,
321    })
322}
323
324fn run_in_shortcut_sta<T, F>(work: F) -> Result<T>
325where
326    T: Send + 'static,
327    F: FnOnce() -> Result<T> + Send + 'static,
328{
329    run_in_sta("shortcut STA worker thread panicked", work)
330}
331
332/// Creates or overwrites a Windows `.lnk` shortcut.
333///
334/// `shortcut_path` must be an absolute `.lnk` path whose parent directory exists.
335/// `target_path` must be an existing absolute path. Use [`ShortcutOptions`] to set
336/// arguments, a working directory, an icon, or a description.
337///
338/// # Errors
339///
340/// Returns [`Error::InvalidInput`] for empty paths, NUL bytes, unsupported extensions,
341/// invalid options, or malformed text fields.
342/// Returns [`Error::PathNotAbsolute`] if a required path is relative.
343/// Returns [`Error::PathDoesNotExist`] if the target path or output parent directory
344/// does not exist.
345/// Returns [`Error::WindowsApi`] if COM or Shell Link APIs report failure.
346///
347/// # Examples
348///
349/// ```no_run
350/// let shortcut = std::env::current_dir()?.join("notepad.lnk");
351/// let options = win_desktop_utils::ShortcutOptions::new()
352///     .description("Open Notepad");
353///
354/// win_desktop_utils::create_shortcut(
355///     &shortcut,
356///     r"C:\Windows\notepad.exe",
357///     &options,
358/// )?;
359/// # Ok::<(), Box<dyn std::error::Error>>(())
360/// ```
361pub fn create_shortcut(
362    shortcut_path: impl AsRef<Path>,
363    target_path: impl AsRef<Path>,
364    options: &ShortcutOptions,
365) -> Result<()> {
366    let shortcut_path = shortcut_path.as_ref();
367    let target_path = target_path.as_ref();
368
369    validate_output_path(shortcut_path, "lnk", "shortcut path cannot be empty")?;
370    validate_existing_absolute_path(
371        target_path,
372        "target path cannot be empty",
373        "target path cannot contain NUL bytes",
374    )?;
375    validate_options(options)?;
376
377    let request = ShortcutRequest {
378        shortcut_path: shortcut_path.to_path_buf(),
379        target_path: target_path.to_path_buf(),
380        options: options.clone(),
381    };
382
383    run_in_shortcut_sta(move || create_shortcut_in_sta(request))
384}
385
386/// Creates or overwrites an Internet Shortcut `.url` file.
387///
388/// `shortcut_path` must be an absolute `.url` path whose parent directory exists.
389/// Surrounding whitespace is trimmed from `url`.
390///
391/// # Errors
392///
393/// Returns [`Error::InvalidInput`] for empty paths, malformed URLs, NUL bytes,
394/// line breaks in the URL, or unsupported extensions.
395/// Returns [`Error::PathNotAbsolute`] if `shortcut_path` is relative.
396/// Returns [`Error::PathDoesNotExist`] if the output parent directory does not exist.
397/// Returns [`Error::Io`] if writing the shortcut file fails.
398///
399/// # Examples
400///
401/// ```
402/// let shortcut = std::env::temp_dir().join(format!(
403///     "win-desktop-utils-docs-{}.url",
404///     std::process::id()
405/// ));
406///
407/// win_desktop_utils::create_url_shortcut(
408///     &shortcut,
409///     "https://docs.rs/win-desktop-utils",
410/// )?;
411/// std::fs::remove_file(shortcut)?;
412/// # Ok::<(), Box<dyn std::error::Error>>(())
413/// ```
414pub fn create_url_shortcut(shortcut_path: impl AsRef<Path>, url: &str) -> Result<()> {
415    let shortcut_path = shortcut_path.as_ref();
416    let url = validate_url(url)?;
417
418    validate_output_path(shortcut_path, "url", "shortcut path cannot be empty")?;
419
420    let body = format!("[InternetShortcut]\r\nURL={url}\r\n");
421    std::fs::write(shortcut_path, body)?;
422
423    Ok(())
424}
425
426#[cfg(test)]
427mod tests {
428    use super::{
429        create_url_shortcut, join_args_for_shortcut, validate_output_path, validate_url,
430        ShortcutOptions,
431    };
432    use std::ffi::OsString;
433
434    #[test]
435    fn shortcut_options_builder_sets_values() {
436        let options = ShortcutOptions::new()
437            .argument("--help")
438            .working_directory(r"C:\Windows")
439            .icon(r"C:\Windows\notepad.exe", 0)
440            .description("Demo shortcut");
441
442        assert_eq!(options.arguments, [OsString::from("--help")]);
443        assert_eq!(options.description.as_deref(), Some("Demo shortcut"));
444        assert!(options.working_directory.is_some());
445        assert!(options.icon.is_some());
446    }
447
448    #[test]
449    fn join_args_quotes_each_argument() {
450        let args = [OsString::from("alpha"), OsString::from("two words")];
451        assert_eq!(join_args_for_shortcut(&args), "\"alpha\" \"two words\"");
452    }
453
454    #[test]
455    fn validate_url_trims_surrounding_whitespace() {
456        assert_eq!(
457            validate_url("  https://example.com/docs  ").unwrap(),
458            "https://example.com/docs"
459        );
460    }
461
462    #[test]
463    fn validate_url_rejects_line_breaks() {
464        let result = validate_url("https://example.com/\r\nIconFile=bad.ico");
465        assert!(matches!(
466            result,
467            Err(crate::Error::InvalidInput("url cannot contain line breaks"))
468        ));
469    }
470
471    #[test]
472    fn validate_output_path_rejects_relative_paths() {
473        let result = validate_output_path(
474            std::path::Path::new("demo.lnk"),
475            "lnk",
476            "shortcut path cannot be empty",
477        );
478        assert!(matches!(result, Err(crate::Error::PathNotAbsolute)));
479    }
480
481    #[test]
482    fn validate_output_path_rejects_wrong_extension() {
483        let path = std::env::temp_dir().join("demo.txt");
484        let result = validate_output_path(&path, "lnk", "shortcut path cannot be empty");
485        assert!(matches!(
486            result,
487            Err(crate::Error::InvalidInput(
488                "shortcut path must use .lnk extension"
489            ))
490        ));
491    }
492
493    #[test]
494    fn create_url_shortcut_writes_url_file() {
495        let path = std::env::temp_dir().join(format!(
496            "win-desktop-utils-url-shortcut-test-{}.url",
497            std::process::id()
498        ));
499        let _ = std::fs::remove_file(&path);
500
501        create_url_shortcut(&path, " https://example.com/docs ").unwrap();
502
503        let body = std::fs::read_to_string(&path).unwrap();
504        assert_eq!(
505            body,
506            "[InternetShortcut]\r\nURL=https://example.com/docs\r\n"
507        );
508
509        std::fs::remove_file(path).unwrap();
510    }
511}