Skip to main content

xei_core/
update.rs

1//! Version check + self-update.
2//!
3//! On startup (when `update_check = true`, default) a background thread asks
4//! GitHub for the latest release tag — non-blocking, silent on any failure,
5//! throttled to one network hit per ~4h via `~/.xei/update_check` (which
6//! caches the found version so throttled launches still banner). When a newer version
7//! exists the welcome screen shows a notice and `:update` swaps the running
8//! binary in place (download → gunzip → atomic rename over `current_exe`),
9//! which works for npm / brew / cargo / curl installs alike.
10
11use std::path::PathBuf;
12use std::process::Command;
13use std::sync::mpsc::{self, Receiver, TryRecvError};
14use std::time::{Duration, SystemTime, UNIX_EPOCH};
15
16/// Re-check at most this often (cached result still banners in between).
17const CHECK_INTERVAL: Duration = Duration::from_secs(4 * 60 * 60);
18
19#[derive(Default)]
20pub struct UpdateState {
21    /// Newer version available (plain semver, no leading `v`).
22    pub latest: Option<String>,
23    /// A self-update finished this session — restart to load it.
24    pub installed: bool,
25    pub installing: bool,
26    check_rx: Option<Receiver<Option<String>>>,
27    /// `:update` before any check finished — install as soon as one lands.
28    install_after_check: bool,
29    install_rx: Option<Receiver<Result<String, String>>>,
30}
31
32impl UpdateState {
33    pub fn new() -> Self {
34        Self::default()
35    }
36
37    /// Kick off the async latest-version lookup. Throttled launches fall
38    /// back to the stamp's cached result so a known update still banners.
39    pub fn start_check(&mut self, current: &str) {
40        if self.check_rx.is_some() {
41            return;
42        }
43        match throttle_state() {
44            Throttle::Ready => self.spawn_check(current),
45            Throttle::Wait(cached) => {
46                if let Some(v) = cached {
47                    if is_newer(&v, current) {
48                        self.latest = Some(v);
49                    }
50                }
51            }
52        }
53    }
54
55    fn spawn_check(&mut self, current: &str) {
56        let current = current.to_string();
57        let (tx, rx) = mpsc::channel();
58        self.check_rx = Some(rx);
59        std::thread::spawn(move || {
60            let found = fetch_latest();
61            write_stamp(found.as_deref());
62            let newer = found.filter(|v| is_newer(v, &current));
63            let _ = tx.send(newer);
64        });
65    }
66
67    /// `:update` with nothing known yet: force a fresh check (bypasses the
68    /// throttle) and install automatically when something newer lands.
69    pub fn check_now_and_install(&mut self, current: &str) -> String {
70        self.install_after_check = true;
71        self.spawn_check(current);
72        "⟳ checking for updates…".into()
73    }
74
75    /// Drain background results; returns a status message when one lands.
76    pub fn poll(&mut self) -> Option<String> {
77        if let Some(rx) = self.check_rx.take() {
78            match rx.try_recv() {
79                Ok(found) => {
80                    self.latest = found;
81                    let auto = std::mem::take(&mut self.install_after_check);
82                    if self.latest.is_some() {
83                        if auto {
84                            return Some(self.start_install());
85                        }
86                        let v = self.latest.as_deref().unwrap_or_default();
87                        return Some(format!(
88                            "⬆ xei v{v} available — :update to install"
89                        ));
90                    } else if auto {
91                        return Some("Already up to date".into());
92                    }
93                }
94                Err(TryRecvError::Empty) => self.check_rx = Some(rx),
95                Err(TryRecvError::Disconnected) => {}
96            }
97        }
98        if let Some(rx) = self.install_rx.take() {
99            match rx.try_recv() {
100                Ok(Ok(msg)) => {
101                    self.installing = false;
102                    self.installed = true;
103                    self.latest = None;
104                    return Some(msg);
105                }
106                Ok(Err(e)) => {
107                    self.installing = false;
108                    return Some(format!("update failed: {e}"));
109                }
110                Err(TryRecvError::Empty) => self.install_rx = Some(rx),
111                Err(TryRecvError::Disconnected) => self.installing = false,
112            }
113        }
114        None
115    }
116
117    /// `:update` — replace the running binary with the latest release build.
118    pub fn start_install(&mut self) -> String {
119        let Some(latest) = self.latest.clone() else {
120            return "Already up to date".into();
121        };
122        if self.installing {
123            return "Update already running…".into();
124        }
125        let Some(triple) = release_triple() else {
126            return format!(
127                "Self-update unsupported on this platform — run: npm i -g xei-editor (or brew upgrade xei) for v{latest}"
128            );
129        };
130        let Ok(exe) = std::env::current_exe() else {
131            return "update: cannot locate current executable".into();
132        };
133        self.installing = true;
134        let (tx, rx) = mpsc::channel();
135        self.install_rx = Some(rx);
136        let msg = format!("⬇ downloading v{latest}…");
137        std::thread::spawn(move || {
138            let _ = tx.send(install_binary(&latest, triple, exe));
139        });
140        msg
141    }
142}
143
144/// Numeric semver compare on `a.b.c`; returns true when `latest` > `current`.
145fn is_newer(latest: &str, current: &str) -> bool {
146    let parse = |s: &str| -> Vec<u64> {
147        s.trim_start_matches('v')
148            .split('.')
149            .map(|p| {
150                p.chars()
151                    .take_while(|c| c.is_ascii_digit())
152                    .collect::<String>()
153                    .parse()
154                    .unwrap_or(0)
155            })
156            .collect()
157    };
158    let (l, c) = (parse(latest), parse(current));
159    for i in 0..l.len().max(c.len()) {
160        let (a, b) = (
161            l.get(i).copied().unwrap_or(0),
162            c.get(i).copied().unwrap_or(0),
163        );
164        if a != b {
165            return a > b;
166        }
167    }
168    false
169}
170
171fn xei_dir() -> PathBuf {
172    let home = std::env::var("HOME")
173        .or_else(|_| std::env::var("USERPROFILE"))
174        .unwrap_or_else(|_| ".".into());
175    PathBuf::from(home).join(".xei")
176}
177
178enum Throttle {
179    /// Interval elapsed — hit the network.
180    Ready,
181    /// Inside the window; carries the cached latest version (if any).
182    Wait(Option<String>),
183}
184
185/// Stamp format: `<unix-ts> [<latest-version>]`.
186fn throttle_state() -> Throttle {
187    let stamp = xei_dir().join("update_check");
188    let now = SystemTime::now()
189        .duration_since(UNIX_EPOCH)
190        .unwrap_or_default()
191        .as_secs();
192    if let Ok(prev) = std::fs::read_to_string(&stamp) {
193        let mut parts = prev.split_whitespace();
194        if let Some(Ok(ts)) = parts.next().map(|p| p.parse::<u64>()) {
195            if now.saturating_sub(ts) < CHECK_INTERVAL.as_secs() {
196                return Throttle::Wait(parts.next().map(|s| s.to_string()));
197            }
198        }
199    }
200    Throttle::Ready
201}
202
203fn write_stamp(latest: Option<&str>) {
204    let now = SystemTime::now()
205        .duration_since(UNIX_EPOCH)
206        .unwrap_or_default()
207        .as_secs();
208    let _ = std::fs::create_dir_all(xei_dir());
209    let body = match latest {
210        Some(v) => format!("{now} {v}"),
211        None => now.to_string(),
212    };
213    let _ = std::fs::write(xei_dir().join("update_check"), body);
214}
215
216/// Latest release tag from GitHub (regardless of comparison).
217fn fetch_latest() -> Option<String> {
218    let out = Command::new("curl")
219        .args([
220            "-fsSL",
221            "--max-time",
222            "5",
223            "-H",
224            "User-Agent: xei-update-check",
225            "https://api.github.com/repos/stremtec/xei/releases/latest",
226        ])
227        .output()
228        .ok()?;
229    if !out.status.success() {
230        return None;
231    }
232    let v: serde_json::Value = serde_json::from_slice(&out.stdout).ok()?;
233    let tag = v.get("tag_name")?.as_str()?;
234    Some(tag.trim_start_matches('v').to_string())
235}
236
237/// Release asset triple for the running platform (self-update targets).
238fn release_triple() -> Option<&'static str> {
239    if cfg!(all(target_os = "macos", target_arch = "aarch64")) {
240        Some("aarch64-apple-darwin")
241    } else if cfg!(all(target_os = "macos", target_arch = "x86_64")) {
242        Some("x86_64-apple-darwin")
243    } else if cfg!(all(target_os = "linux", target_arch = "aarch64")) {
244        Some("aarch64-unknown-linux-gnu")
245    } else if cfg!(all(target_os = "linux", target_arch = "x86_64")) {
246        Some("x86_64-unknown-linux-gnu")
247    } else {
248        // Windows can't replace a running .exe in place — installer path there.
249        None
250    }
251}
252
253/// Download + gunzip + atomic rename over the running executable.
254fn install_binary(latest: &str, triple: &str, exe: PathBuf) -> Result<String, String> {
255    let url = format!(
256        "https://github.com/stremtec/xei/releases/download/v{latest}/xei-{triple}.gz"
257    );
258    let tmp = exe.with_extension(format!("update-{latest}"));
259    let tmp_s = tmp.display().to_string();
260    let exe_s = exe.display().to_string();
261    let script = format!(
262        "curl -fsSL --max-time 120 '{url}' | gunzip > '{tmp_s}' && chmod +x '{tmp_s}' && mv '{tmp_s}' '{exe_s}'"
263    );
264    let out = Command::new("sh")
265        .arg("-c")
266        .arg(&script)
267        .output()
268        .map_err(|e| e.to_string())?;
269    if out.status.success() {
270        Ok(format!("✓ updated to v{latest} — restart xei to use it"))
271    } else {
272        let _ = std::fs::remove_file(&tmp);
273        let err = String::from_utf8_lossy(&out.stderr);
274        Err(err
275            .lines()
276            .next()
277            .unwrap_or("download failed (permissions? network?)")
278            .to_string())
279    }
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285
286    #[test]
287    fn semver_compare() {
288        assert!(is_newer("3.0.2", "3.0.1"));
289        assert!(is_newer("3.1.0", "3.0.9"));
290        assert!(is_newer("4.0.0", "3.9.9"));
291        assert!(!is_newer("3.0.1", "3.0.1"));
292        assert!(!is_newer("3.0.0", "3.0.1"));
293        assert!(is_newer("v3.0.2", "3.0.1"));
294        // extra components / junk tolerated
295        assert!(is_newer("3.0.1.1", "3.0.1"));
296        assert!(!is_newer("garbage", "3.0.1"));
297    }
298}