1use std::path::PathBuf;
12use std::process::Command;
13use std::sync::mpsc::{self, Receiver, TryRecvError};
14use std::time::{Duration, SystemTime, UNIX_EPOCH};
15
16const CHECK_INTERVAL: Duration = Duration::from_secs(4 * 60 * 60);
18
19#[derive(Default)]
20pub struct UpdateState {
21 pub latest: Option<String>,
23 pub installed: bool,
25 pub installing: bool,
26 check_rx: Option<Receiver<Option<String>>>,
27 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 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, ¤t));
63 let _ = tx.send(newer);
64 });
65 }
66
67 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 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 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
144fn 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 Ready,
181 Wait(Option<String>),
183}
184
185fn 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
216fn 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
237fn 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 None
250 }
251}
252
253fn 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 assert!(is_newer("3.0.1.1", "3.0.1"));
296 assert!(!is_newer("garbage", "3.0.1"));
297 }
298}