Skip to main content

zng_ext_setup/task/
create_shortcut.rs

1#![cfg(any(windows, target_os = "linux"))]
2
3use crate::task::InstallTaskError;
4use crate::task::{escape_arg, path_utf8};
5use std::fmt::Write as _;
6use std::{io, path::PathBuf};
7
8use super::SetupTaskError;
9
10/// Setup task that creates a shortcut to an executable file.
11pub enum CreateShortcut {}
12
13// https://docs.rs/mslnk/0.1.8/mslnk/
14
15fn common_prepare_install(mut c: CreateShortcutConfig) -> CreateShortcutConfig {
16    if c.app_id.is_empty() {
17        c.app_id = zng_env::about().windows_aumid().to_string()
18    }
19    if c.working_dir.as_os_str().is_empty() {
20        c.working_dir = c.target_file.parent().unwrap_or_else(|| std::path::Path::new("")).to_path_buf()
21    }
22    if c.name.is_empty()
23        && let Some(name) = c.link_file.file_name()
24        && let Some(name) = name.to_str()
25    {
26        c.name = name.to_owned();
27    }
28    c
29}
30
31impl super::SetupTask for CreateShortcut {
32    type InstallConfig = CreateShortcutConfig;
33
34    type PrepareInstall = PrepareInstallData;
35
36    type Install = InstallData;
37
38    fn task_type_id() -> super::TaskTypeId {
39        "zng-setup/CreateShortcut".into()
40    }
41
42    #[cfg(windows)]
43    async fn prepare_install(args: super::PrepareInstallArgs<Self>) -> Result<Self::PrepareInstall, SetupTaskError> {
44        let c = common_prepare_install(args.config);
45
46        let working_dir = path_utf8(c.working_dir)?;
47        let icon = path_utf8(c.icon)?;
48
49        let mut args = String::new();
50        let mut sep = "";
51        for arg in &c.args {
52            write!(&mut args, "{sep}{}", escape_arg(arg)).unwrap();
53            sep = " ";
54        }
55
56        Ok(PrepareInstallData {
57            link_file: c.link_file.with_added_extension("lnk"),
58            target_file: c.target_file,
59            working_dir,
60            arguments: args,
61            app_id: c.app_id,
62            name: c.name,
63            icon,
64        })
65    }
66
67    #[cfg(target_os = "linux")]
68    async fn prepare_install(args: super::PrepareInstallArgs<Self>) -> Result<Self::PrepareInstall, SetupTaskError> {
69        let c = common_prepare_install(args.config);
70
71        let mut desktop = "[Desktop Entry]\nVersion=1.0\nType=Application\n".to_owned();
72
73        write!(&mut desktop, "Exec={}", escape_arg(&path_utf8(c.target_file)?)).unwrap();
74        for arg in c.args {
75            write!(&mut desktop, " {}", escape_arg(&arg)).unwrap();
76        }
77        writeln!(&mut desktop).unwrap();
78
79        if !c.working_dir.as_os_str().is_empty() {
80            writeln!(&mut desktop, "Path={}", path_utf8(c.working_dir)?).unwrap();
81        }
82
83        if !c.icon.as_os_str().is_empty() {
84            writeln!(&mut desktop, "Icon={}", path_utf8(c.icon)?).unwrap();
85        }
86
87        let name = c
88            .name
89            .replace("\\", r"\\")
90            .replace("\n", r"\n")
91            .replace("\t", r"\t")
92            .replace("\r", r"\r");
93        if name.is_empty() {
94            return Err(SetupTaskError::io(
95                c.link_file,
96                io::Error::new(io::ErrorKind::InvalidData, "missing name"),
97            ));
98        }
99        writeln!(&mut desktop, "Name={name}").unwrap();
100
101        Ok(PrepareInstallData {
102            link_file: c.link_file,
103            desktop,
104        })
105    }
106
107    #[cfg(windows)]
108    async fn install(args: super::InstallArgs<Self>) -> Result<Self::Install, InstallTaskError<Self::Install>> {
109        let data = InstallData {
110            link_file: args.data.link_file.clone(),
111        };
112
113        // must run in clean thread to avoid COM issues
114        let (sx, rx) = zng_task::channel::rendezvous();
115        let r = std::thread::spawn(move || {
116            let r = windows_install(args.data);
117            sx.send_blocking(()).unwrap();
118            r
119        });
120        let _ = rx.recv().await;
121        let r = match r.join() {
122            Ok(r) => r,
123            Err(p) => std::panic::resume_unwind(p),
124        };
125        if let Err(e) = r {
126            return Err(InstallTaskError {
127                error: SetupTaskError::other(e),
128                // link_file probably does not exist, but `Some` here indicates that the failed uninstall can
129                // be cleanup and `uninstall` will not error if the link file does not exist.
130                clean_data: Some(data),
131            });
132        }
133        Ok(data)
134    }
135
136    #[cfg(target_os = "linux")]
137    async fn install(args: super::InstallArgs<Self>) -> Result<Self::Install, InstallTaskError<Self::Install>> {
138        fn write(link_file: PathBuf, desktop: String) -> io::Result<()> {
139            use std::io::Write as _;
140            use std::os::unix::fs::PermissionsExt as _;
141
142            let mut f = std::fs::File::create(&link_file)?;
143            f.write_all(desktop.as_bytes())?;
144
145            let mut perms = f.metadata()?.permissions();
146            perms.set_mode(0o755);
147            std::fs::set_permissions(link_file, perms)?;
148
149            Ok(())
150        }
151
152        let data = InstallData {
153            link_file: args.data.link_file.clone(),
154        };
155
156        let link_file = args.data.link_file.clone();
157
158        if let Err(e) = zng_task::wait(move || write(link_file, args.data.desktop)).await {
159            return Err(InstallTaskError {
160                error: SetupTaskError::io(args.data.link_file, e),
161                // link_file probably does not exist, but `Some` here indicates that the failed uninstall can
162                // be cleanup and `uninstall` will not error if the link file does not exist.
163                clean_data: Some(data),
164            });
165        }
166        Ok(data)
167    }
168
169    async fn cancel_install(_: super::CancelInstallArgs<Self>) -> Result<(), SetupTaskError> {
170        Ok(())
171    }
172
173    async fn validate_uninstall(args: super::ValidateUninstallArgs<Self>) -> Result<Self::Install, SetupTaskError> {
174        Ok(args.data)
175    }
176
177    async fn uninstall(args: super::UninstallArgs<Self>) -> Result<(), SetupTaskError> {
178        if let Err(e) = zng_task::fs::remove_file(&args.data.link_file).await
179            && !matches!(e.kind(), io::ErrorKind::NotFound)
180        {
181            return Err(SetupTaskError::io(args.data.link_file, e));
182        }
183        Ok(())
184    }
185}
186
187/// Config for [`CreateShortcut`].
188pub struct CreateShortcutConfig {
189    // TODO(breaking) non_exhaustive
190    /// Path to the shortcut, without extension.
191    pub link_file: PathBuf,
192    /// Path to the shortcut target executable file.
193    pub target_file: PathBuf,
194    /// Path to the initial `current_dir` of the executable.
195    ///
196    /// # Default
197    ///
198    /// Empty is the `target_file` parent executable.
199    pub working_dir: PathBuf,
200    /// Arguments to pass the executable.
201    pub args: Vec<String>,
202
203    /// Globally unique app ID.
204    ///
205    /// This is only used on Windows to set the AUMID on the shortcut, this **must** be
206    /// the same value as `zng::env::About::windows_aumid` on the app executable. Note that
207    /// if the app does not have a shortcut on the Start Menu that defines the ID notifications
208    /// and other shell integrations will not work properly.
209    ///
210    /// # Default
211    ///
212    /// If this is empty the [`zng::env::About::windows_aumid`] value is used. Note that this is only valid
213    /// if the setup app is defined on the same app executable.
214    ///
215    /// [`zng::env::About::windows_aumid`]: zng_env::About::windows_aumid
216    pub app_id: String,
217
218    /// Optional display name of the shortcut.
219    ///
220    /// # Default
221    ///
222    /// Empty is the `link_file` file name.
223    pub name: String,
224    /// Optional icon for the shortcut.
225    ///
226    /// # Default
227    ///
228    /// Empty is the `target_file` icon 0 in Windows, and no icon in Linux.
229    pub icon: PathBuf,
230}
231impl CreateShortcutConfig {
232    /// New with minimal required config.
233    pub fn new(link_file: PathBuf, target_file: PathBuf) -> Self {
234        Self {
235            link_file,
236            target_file,
237            working_dir: PathBuf::new(),
238            args: vec![],
239            app_id: String::new(),
240            name: String::new(),
241            icon: PathBuf::new(),
242        }
243    }
244}
245
246#[cfg(target_os = "linux")]
247#[doc(hidden)]
248#[derive(Debug, PartialEq, Clone, serde::Serialize, serde::Deserialize)]
249pub struct PrepareInstallData {
250    link_file: PathBuf,
251    desktop: String,
252}
253
254#[cfg(windows)]
255#[doc(hidden)]
256#[derive(Debug, PartialEq, Clone, serde::Serialize, serde::Deserialize)]
257pub struct PrepareInstallData {
258    link_file: PathBuf,
259    target_file: PathBuf,
260    working_dir: String,
261    arguments: String,
262    app_id: String,
263    name: String,
264    icon: String,
265}
266
267#[doc(hidden)]
268#[derive(Debug, PartialEq, Clone, serde::Serialize, serde::Deserialize)]
269pub struct InstallData {
270    link_file: PathBuf,
271}
272
273/// Must run in own thread to avoid COM issues
274#[cfg(windows)]
275fn windows_install(d: PrepareInstallData) -> windows::core::Result<()> {
276    use std::path::Path;
277
278    use windows::{
279        Win32::{
280            Storage::EnhancedStorage::PKEY_AppUserModel_ID,
281            System::{
282                Com::{
283                    CLSCTX_INPROC_SERVER, COINIT_MULTITHREADED, CoCreateInstance, CoInitializeEx, CoUninitialize, IPersistFile,
284                    StructuredStorage::VariantToPropVariant,
285                },
286                Variant::VARIANT,
287            },
288            UI::Shell::{IShellLinkW, PropertiesSystem::IPropertyStore, ShellLink},
289        },
290        core::{Interface as _, PCWSTR},
291    };
292
293    fn wide(s: impl AsRef<std::ffi::OsStr>) -> Vec<u16> {
294        use std::os::windows::ffi::OsStrExt;
295
296        s.as_ref().encode_wide().chain(std::iter::once(0)).collect()
297    }
298
299    unsafe {
300        let _ok = CoInitializeEx(None, COINIT_MULTITHREADED).is_ok();
301        debug_assert!(_ok, "expected to run in a new thread");
302        struct ComDeinit;
303        impl Drop for ComDeinit {
304            fn drop(&mut self) {
305                unsafe {
306                    CoUninitialize();
307                }
308            }
309        }
310        let _com_deinit = ComDeinit;
311
312        let link: IShellLinkW = CoCreateInstance(&ShellLink, None, CLSCTX_INPROC_SERVER)?;
313
314        let target = wide(&d.target_file);
315        link.SetPath(PCWSTR(target.as_ptr()))?;
316
317        if !d.working_dir.is_empty() {
318            let value = wide(&d.working_dir);
319            link.SetWorkingDirectory(PCWSTR(value.as_ptr()))?;
320        }
321
322        if !d.arguments.is_empty() {
323            let value = wide(&d.arguments);
324            link.SetArguments(PCWSTR(value.as_ptr()))?;
325        }
326
327        if !d.name.is_empty() {
328            let value = wide(&d.name);
329            link.SetDescription(PCWSTR(value.as_ptr()))?;
330        }
331
332        if !d.icon.is_empty() {
333            let value = wide(&d.icon);
334            link.SetIconLocation(PCWSTR(value.as_ptr()), 0)?;
335        }
336
337        // System.AppUserModel.ID
338        if !d.app_id.is_empty() {
339            let store: IPropertyStore = link.cast()?;
340
341            let variant = VARIANT::from(d.app_id.as_str());
342            let value = VariantToPropVariant(&variant)?;
343
344            store.SetValue(&PKEY_AppUserModel_ID, &value)?;
345            store.Commit()?;
346        }
347
348        let persist: IPersistFile = link.cast()?;
349        let output = wide(Path::new(&d.link_file));
350        persist.Save(PCWSTR(output.as_ptr()), true)?;
351    }
352    todo!()
353}