Skip to main content

nex_core/
updater.rs

1use std::fmt::{Display, Formatter};
2use std::path::{Path, PathBuf};
3
4const INSTALLED_UPDATER_RELATIVE_PATH: &str = "scripts/update-nex.ps1";
5const INSTALLED_UPDATER_FALLBACK_NAME: &str = "update-nex.ps1";
6const DEV_UPDATER_RELATIVE_PATH: &str = "scripts/windows/update-nex.ps1";
7const MAX_ANCESTOR_SCAN_DEPTH: usize = 10;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum UpdateChannel {
11    Stable,
12    Beta,
13}
14
15impl UpdateChannel {
16    pub fn as_arg(self) -> &'static str {
17        match self {
18            Self::Stable => "stable",
19            Self::Beta => "beta",
20        }
21    }
22}
23
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum UpdateLaunchError {
26    UnsupportedPlatform,
27    EnvironmentUnavailable(String),
28    ScriptNotFound { checked_paths: Vec<PathBuf> },
29    LaunchFailed(String),
30}
31
32impl Display for UpdateLaunchError {
33    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
34        match self {
35            Self::UnsupportedPlatform => write!(f, "updater is only available on Windows"),
36            Self::EnvironmentUnavailable(message) => write!(f, "{message}"),
37            Self::ScriptNotFound { checked_paths } => {
38                if checked_paths.is_empty() {
39                    write!(f, "update script not found")
40                } else {
41                    let joined = checked_paths
42                        .iter()
43                        .map(|path| path.display().to_string())
44                        .collect::<Vec<_>>()
45                        .join(", ");
46                    write!(f, "update script not found (checked: {joined})")
47                }
48            }
49            Self::LaunchFailed(message) => write!(f, "{message}"),
50        }
51    }
52}
53
54impl std::error::Error for UpdateLaunchError {}
55
56pub fn launch_updater(channel: UpdateChannel) -> Result<PathBuf, UpdateLaunchError> {
57    let exe_path = std::env::current_exe().map_err(|error| {
58        UpdateLaunchError::EnvironmentUnavailable(format!(
59            "could not resolve current executable path: {error}"
60        ))
61    })?;
62    let cwd = std::env::current_dir().map_err(|error| {
63        UpdateLaunchError::EnvironmentUnavailable(format!(
64            "could not resolve current working directory: {error}"
65        ))
66    })?;
67    let checked_paths = updater_script_candidates(&exe_path, &cwd);
68    let script_path = checked_paths
69        .iter()
70        .find(|candidate| candidate.exists())
71        .cloned()
72        .ok_or_else(|| UpdateLaunchError::ScriptNotFound {
73            checked_paths: checked_paths.clone(),
74        })?;
75
76    launch_updater_script(script_path.as_path(), channel)?;
77    Ok(script_path)
78}
79
80#[cfg(target_os = "windows")]
81fn launch_updater_script(
82    script_path: &Path,
83    channel: UpdateChannel,
84) -> Result<(), UpdateLaunchError> {
85    std::process::Command::new("powershell.exe")
86        .arg("-NoProfile")
87        .arg("-ExecutionPolicy")
88        .arg("Bypass")
89        .arg("-File")
90        .arg(script_path)
91        .arg("-Channel")
92        .arg(channel.as_arg())
93        .spawn()
94        .map_err(|error| {
95            UpdateLaunchError::LaunchFailed(format!(
96                "failed to launch updater script '{}': {error}",
97                script_path.display()
98            ))
99        })?;
100    Ok(())
101}
102
103#[cfg(not(target_os = "windows"))]
104fn launch_updater_script(
105    _script_path: &Path,
106    _channel: UpdateChannel,
107) -> Result<(), UpdateLaunchError> {
108    Err(UpdateLaunchError::UnsupportedPlatform)
109}
110
111fn updater_script_candidates(exe_path: &Path, cwd: &Path) -> Vec<PathBuf> {
112    let mut candidates = Vec::new();
113
114    if let Some(exe_dir) = exe_path.parent() {
115        if let Some(install_root) = exe_dir.parent() {
116            push_unique(
117                &mut candidates,
118                install_root.join(INSTALLED_UPDATER_RELATIVE_PATH),
119            );
120            push_unique(
121                &mut candidates,
122                install_root.join(INSTALLED_UPDATER_FALLBACK_NAME),
123            );
124        }
125        collect_ancestor_candidates(exe_dir, &mut candidates, DEV_UPDATER_RELATIVE_PATH);
126    }
127
128    collect_ancestor_candidates(cwd, &mut candidates, DEV_UPDATER_RELATIVE_PATH);
129
130    candidates
131}
132
133fn collect_ancestor_candidates(base: &Path, out: &mut Vec<PathBuf>, relative: &str) {
134    for ancestor in base.ancestors().take(MAX_ANCESTOR_SCAN_DEPTH) {
135        push_unique(out, ancestor.join(relative));
136    }
137}
138
139fn push_unique(paths: &mut Vec<PathBuf>, candidate: PathBuf) {
140    if !paths.iter().any(|existing| existing == &candidate) {
141        paths.push(candidate);
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::{
148        updater_script_candidates, DEV_UPDATER_RELATIVE_PATH, INSTALLED_UPDATER_RELATIVE_PATH,
149    };
150
151    #[test]
152    fn updater_candidates_prefer_installed_layout_before_repo_fallback() {
153        let root =
154            std::env::temp_dir().join(format!("nex-updater-installed-{}", std::process::id()));
155        let exe_path = root.join("bin/nex.exe");
156        let cwd = root.clone();
157
158        let candidates = updater_script_candidates(&exe_path, &cwd);
159
160        assert_eq!(candidates[0], root.join(INSTALLED_UPDATER_RELATIVE_PATH));
161        assert_eq!(candidates[1], root.join("update-nex.ps1"));
162    }
163
164    #[test]
165    fn updater_candidates_include_repo_style_script_lookup() {
166        let root = std::env::temp_dir().join(format!("nex-updater-repo-{}", std::process::id()));
167        let repo = root.join("repo");
168        let exe_path = repo.join("target/debug/nex.exe");
169        let cwd = repo.join("apps/core");
170
171        let candidates = updater_script_candidates(&exe_path, &cwd);
172
173        assert!(candidates
174            .iter()
175            .any(|candidate| candidate == &repo.join(DEV_UPDATER_RELATIVE_PATH)));
176    }
177
178    #[test]
179    fn updater_candidates_are_deduplicated() {
180        let repo = std::env::temp_dir().join(format!("nex-updater-dedupe-{}", std::process::id()));
181        let exe_path = repo.join("target/debug/nex.exe");
182        let cwd = repo.join("target/debug");
183
184        let candidates = updater_script_candidates(&exe_path, &cwd);
185        let unique = candidates.iter().collect::<std::collections::BTreeSet<_>>();
186
187        assert_eq!(candidates.len(), unique.len());
188    }
189}