Skip to main content

zng_ext_setup/task/
create_shortcut.rs

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