Skip to main content

update_rs/
cmd.rs

1#[cfg(not(feature = "tracing"))]
2use log::debug;
3#[cfg(feature = "tracing")]
4use tracing::debug;
5
6use crate::{Error, RESUME_FLAG, UpdateState};
7use human_errors::ResultExt;
8use std::ffi::OsString;
9use std::{path::Path, process::Command};
10
11#[cfg(windows)]
12use std::os::windows::process::CommandExt;
13
14#[cfg(test)]
15use mockall::automock;
16
17#[cfg(windows)]
18mod windows {
19    /// Detach the relaunched process from the current console so it survives the
20    /// parent exiting, and give it its own process group.
21    pub const DETACHED_PROCESS: u32 = 0x0000_0008;
22    pub const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
23}
24
25pub(crate) fn default() -> Box<dyn Launcher + Send + Sync> {
26    Box::new(DefaultLauncher::new())
27}
28
29/// Launches the application binary to drive the next phase of an update.
30///
31/// Every method has a default implementation, so the simplest custom launcher is
32/// an empty `impl Launcher for MyLauncher {}` (equivalent to a bare
33/// [`DefaultLauncher`]). Customise as much or as little as you need:
34///
35/// - override [`resume_args`](Launcher::resume_args) to change *how* the update
36///   state is handed to the relaunched process (e.g. via a sub-command rather
37///   than the default [`RESUME_FLAG`](crate::RESUME_FLAG));
38/// - override [`extra_args`](Launcher::extra_args) /
39///   [`extra_envs`](Launcher::extra_envs) to add your own arguments / environment
40///   variables to the relaunch ([`DefaultLauncher`] exposes builders for these,
41///   so you usually don't need a custom launcher just for that);
42/// - override [`launch`](Launcher::launch) for complete control over the relaunch
43///   command, or [`spawn`](Launcher::spawn) to change how the child is started.
44///
45/// Install a custom launcher with
46/// [`UpdateManager::with_launcher`](crate::UpdateManager::with_launcher).
47#[cfg_attr(test, automock)]
48pub trait Launcher {
49    /// The arguments that carry the serialized resume `state_json` to the
50    /// relaunched process.
51    ///
52    /// The default passes the library's [`RESUME_FLAG`](crate::RESUME_FLAG)
53    /// followed by the JSON, which a consuming `main()` detects (before any other
54    /// argument parsing) and forwards to
55    /// [`resume_from_arg`](crate::UpdateManager::resume_from_arg). Override it to
56    /// use a different convention — for example handing the state to a
57    /// sub-command:
58    ///
59    /// ```
60    /// use std::ffi::OsString;
61    /// use update_rs::Launcher;
62    ///
63    /// struct SubcommandLauncher;
64    /// impl Launcher for SubcommandLauncher {
65    ///     fn resume_args(&self, state_json: &str) -> Vec<OsString> {
66    ///         vec!["update".into(), "--state".into(), state_json.into()]
67    ///     }
68    /// }
69    /// ```
70    fn resume_args(&self, state_json: &str) -> Vec<OsString> {
71        vec![RESUME_FLAG.into(), state_json.into()]
72    }
73
74    /// Extra command-line arguments to append to the relaunch command, after the
75    /// [`resume_args`](Launcher::resume_args).
76    ///
77    /// The default adds none; [`DefaultLauncher`] returns the arguments configured
78    /// via [`DefaultLauncher::with_arg`].
79    fn extra_args(&self) -> Vec<OsString> {
80        Vec::new()
81    }
82
83    /// Extra environment variables to set on the relaunched process (added to,
84    /// not replacing, the inherited environment).
85    ///
86    /// The default sets none; [`DefaultLauncher`] returns the variables configured
87    /// via [`DefaultLauncher::with_env`].
88    fn extra_envs(&self) -> Vec<(OsString, OsString)> {
89        Vec::new()
90    }
91
92    /// Build and spawn the command that relaunches `app_path` to continue the
93    /// update with `state`.
94    ///
95    /// The default builds `app_path <`[`resume_args`](Launcher::resume_args)`>
96    /// <`[`extra_args`](Launcher::extra_args)`>` with the
97    /// [`extra_envs`](Launcher::extra_envs) and the platform
98    /// [`detach`](Launcher::detach) flags, then [`spawn`](Launcher::spawn)s it.
99    /// Override this for complete control over the relaunch command, reusing
100    /// [`resume_args`](Launcher::resume_args), [`detach`](Launcher::detach) and
101    /// [`spawn`](Launcher::spawn) as needed.
102    fn launch(&self, app_path: &Path, state: &UpdateState) -> Result<(), Error> {
103        let state_json = serde_json::to_string(state).wrap_system_err(
104            "Failed to serialize the update state into a JSON payload.",
105            &["Please report this issue to the application's maintainers."],
106        )?;
107
108        debug!(
109            "Launching '{}' to perform the '{}' phase of the update.",
110            app_path.display(),
111            state.phase
112        );
113
114        let mut cmd = Command::new(app_path);
115        cmd.args(self.resume_args(&state_json));
116        cmd.args(self.extra_args());
117        cmd.envs(self.extra_envs());
118        self.detach(&mut cmd);
119
120        self.spawn(cmd).wrap_system_err(
121            format!(
122                "Could not launch the new application version to continue the update process (-> {} phase).",
123                state.phase
124            ),
125            &["Please report this issue to the application's maintainers, or try updating manually by downloading the latest release yourself."],
126        )
127    }
128
129    /// Apply the platform-specific flags that detach the relaunched process from
130    /// the current console so it survives the parent exiting (a detached process
131    /// in its own group on Windows). A no-op on non-Windows platforms. Exposed so
132    /// custom [`launch`](Launcher::launch) implementations can reuse it.
133    fn detach(&self, cmd: &mut Command) {
134        #[cfg(windows)]
135        cmd.creation_flags(windows::DETACHED_PROCESS | windows::CREATE_NEW_PROCESS_GROUP);
136        #[cfg(not(windows))]
137        let _ = cmd;
138    }
139
140    /// Spawn the prepared [`Command`]. The default starts the child process and
141    /// returns immediately. This is the single seam the default
142    /// [`launch`](Launcher::launch) relies on, so tests can mock it.
143    fn spawn(&self, mut cmd: Command) -> Result<(), Error> {
144        cmd.spawn().wrap_user_err(
145            "Failed to launch the application to complete the update process.",
146            &[
147                "Try re-running the update.",
148                "Download the latest release and install it manually if the problem continues.",
149            ],
150        )?;
151
152        Ok(())
153    }
154}
155
156/// The default [`Launcher`]: relaunches with the library's
157/// [`RESUME_FLAG`](crate::RESUME_FLAG) and spawns a detached child process. Used
158/// unless [`UpdateManager::with_launcher`](crate::UpdateManager::with_launcher)
159/// installs a different one.
160///
161/// It can carry extra command-line arguments and environment variables through to
162/// the relaunched process, so the common cases need no custom [`Launcher`]:
163///
164/// ```
165/// use update_rs::DefaultLauncher;
166///
167/// let launcher = DefaultLauncher::new()
168///     .with_arg("--updated")
169///     .with_env("APP_UPDATING", "1");
170/// # let _ = launcher;
171/// ```
172#[derive(Debug, Default, Clone)]
173pub struct DefaultLauncher {
174    args: Vec<OsString>,
175    envs: Vec<(OsString, OsString)>,
176}
177
178impl DefaultLauncher {
179    /// Create a default launcher with no extra arguments or environment variables.
180    pub fn new() -> Self {
181        Self::default()
182    }
183
184    /// Append a custom argument to every relaunch, after the resume arguments.
185    /// Call repeatedly to add several.
186    pub fn with_arg(mut self, arg: impl Into<OsString>) -> Self {
187        self.args.push(arg.into());
188        self
189    }
190
191    /// Append several custom arguments to every relaunch.
192    pub fn with_args<I, A>(mut self, args: I) -> Self
193    where
194        I: IntoIterator<Item = A>,
195        A: Into<OsString>,
196    {
197        self.args.extend(args.into_iter().map(Into::into));
198        self
199    }
200
201    /// Set a custom environment variable on every relaunched process. Call
202    /// repeatedly to set several.
203    pub fn with_env(mut self, key: impl Into<OsString>, value: impl Into<OsString>) -> Self {
204        self.envs.push((key.into(), value.into()));
205        self
206    }
207
208    /// Set several custom environment variables on every relaunched process.
209    pub fn with_envs<I, K, V>(mut self, envs: I) -> Self
210    where
211        I: IntoIterator<Item = (K, V)>,
212        K: Into<OsString>,
213        V: Into<OsString>,
214    {
215        self.envs
216            .extend(envs.into_iter().map(|(k, v)| (k.into(), v.into())));
217        self
218    }
219}
220
221impl Launcher for DefaultLauncher {
222    fn extra_args(&self) -> Vec<OsString> {
223        self.args.clone()
224    }
225
226    fn extra_envs(&self) -> Vec<(OsString, OsString)> {
227        self.envs.clone()
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234    use crate::UpdatePhase;
235    use std::sync::Mutex;
236
237    /// A [`Launcher`] that captures the prepared [`Command`] instead of spawning
238    /// it, so we can assert what the default `launch` would run.
239    struct CapturingLauncher {
240        captured: Mutex<Option<Command>>,
241    }
242
243    impl CapturingLauncher {
244        fn new() -> Self {
245            Self {
246                captured: Mutex::new(None),
247            }
248        }
249
250        fn args(&self) -> Vec<String> {
251            self.captured
252                .lock()
253                .unwrap()
254                .as_ref()
255                .unwrap()
256                .get_args()
257                .map(|a| a.to_string_lossy().into_owned())
258                .collect()
259        }
260
261        fn env(&self) -> Vec<(String, Option<String>)> {
262            self.captured
263                .lock()
264                .unwrap()
265                .as_ref()
266                .unwrap()
267                .get_envs()
268                .map(|(k, v)| {
269                    (
270                        k.to_string_lossy().into_owned(),
271                        v.map(|v| v.to_string_lossy().into_owned()),
272                    )
273                })
274                .collect()
275        }
276    }
277
278    impl Launcher for CapturingLauncher {
279        fn spawn(&self, cmd: Command) -> Result<(), Error> {
280            *self.captured.lock().unwrap() = Some(cmd);
281            Ok(())
282        }
283    }
284
285    #[test]
286    fn default_launch_passes_the_resume_flag_and_state() {
287        let launcher = CapturingLauncher::new();
288        let state = UpdateState {
289            phase: UpdatePhase::Replace,
290            ..Default::default()
291        };
292
293        launcher
294            .launch(Path::new("app"), &state)
295            .expect("the capturing launcher never fails");
296
297        let args = launcher.args();
298        assert_eq!(args.len(), 2, "only the resume flag and state: {args:?}");
299        assert_eq!(args[0], RESUME_FLAG);
300        assert!(
301            args[1].contains("\"phase\":\"replace\""),
302            "the serialized state should follow the resume flag: {args:?}"
303        );
304        assert!(
305            launcher.env().is_empty(),
306            "the default launch sets no environment variables"
307        );
308    }
309
310    /// A launcher that hands the update state to an `update --state <json>`
311    /// sub-command instead of the default resume flag (Git-Tool's convention).
312    struct SubcommandLauncher {
313        inner: CapturingLauncher,
314    }
315
316    impl Launcher for SubcommandLauncher {
317        fn resume_args(&self, state_json: &str) -> Vec<OsString> {
318            vec!["update".into(), "--state".into(), state_json.into()]
319        }
320
321        fn spawn(&self, cmd: Command) -> Result<(), Error> {
322            self.inner.spawn(cmd)
323        }
324    }
325
326    #[test]
327    fn launch_honours_a_custom_resume_args_convention() {
328        let launcher = SubcommandLauncher {
329            inner: CapturingLauncher::new(),
330        };
331        let state = UpdateState {
332            phase: UpdatePhase::Replace,
333            ..Default::default()
334        };
335
336        launcher
337            .launch(Path::new("app"), &state)
338            .expect("the capturing launcher never fails");
339
340        let args = launcher.inner.args();
341        assert_eq!(&args[..2], &["update", "--state"]);
342        assert!(
343            args[2].contains("\"phase\":\"replace\""),
344            "the serialized state should follow the sub-command: {args:?}"
345        );
346        assert!(
347            !args.iter().any(|a| a == RESUME_FLAG),
348            "the default resume flag should have been replaced: {args:?}"
349        );
350    }
351
352    /// A launcher with extra arguments / environment variables, to exercise the
353    /// default `launch`'s handling of [`Launcher::extra_args`] /
354    /// [`Launcher::extra_envs`] (the seam `DefaultLauncher` configures).
355    struct ExtraLauncher {
356        inner: CapturingLauncher,
357    }
358
359    impl Launcher for ExtraLauncher {
360        fn extra_args(&self) -> Vec<OsString> {
361            vec!["--updated".into()]
362        }
363
364        fn extra_envs(&self) -> Vec<(OsString, OsString)> {
365            vec![("APP_UPDATING".into(), "1".into())]
366        }
367
368        fn spawn(&self, cmd: Command) -> Result<(), Error> {
369            self.inner.spawn(cmd)
370        }
371    }
372
373    #[test]
374    fn launch_appends_extra_args_and_env_after_the_resume_flag() {
375        let launcher = ExtraLauncher {
376            inner: CapturingLauncher::new(),
377        };
378        let state = UpdateState {
379            phase: UpdatePhase::Replace,
380            ..Default::default()
381        };
382
383        launcher
384            .launch(Path::new("app"), &state)
385            .expect("the capturing launcher never fails");
386
387        let args = launcher.inner.args();
388        // resume flag + serialized state come first, then the extra arguments.
389        assert_eq!(args[0], RESUME_FLAG);
390        assert_eq!(args[2], "--updated");
391        assert!(
392            launcher
393                .inner
394                .env()
395                .contains(&("APP_UPDATING".to_string(), Some("1".to_string()))),
396            "the extra environment variable should be set on the relaunch"
397        );
398    }
399
400    #[test]
401    fn default_launcher_builders_collect_args_and_env() {
402        let launcher = DefaultLauncher::new()
403            .with_arg("--updated")
404            .with_args(["--from", "v1"])
405            .with_env("APP_UPDATING", "1")
406            .with_envs([("CHANNEL", "beta")]);
407
408        assert_eq!(
409            launcher.extra_args(),
410            [
411                OsString::from("--updated"),
412                OsString::from("--from"),
413                OsString::from("v1")
414            ]
415        );
416        assert_eq!(
417            launcher.extra_envs(),
418            [
419                (OsString::from("APP_UPDATING"), OsString::from("1")),
420                (OsString::from("CHANNEL"), OsString::from("beta")),
421            ]
422        );
423    }
424}