Skip to main content

win_desktop_utils/
shortcuts.rs

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