1use std::io::Write;
14use std::path::Path;
15use std::process::{Command, Stdio};
16use std::sync::mpsc::{self, Receiver};
17use std::thread;
18use std::time::{Duration, Instant};
19
20use crate::remote::RemoteTarget;
21
22const RELEASES_URL: &str = "https://github.com/akivajp/rigtop/releases/download";
24const LATEST_URL: &str = "https://github.com/akivajp/rigtop/releases/latest";
26
27pub fn target_version() -> &'static str {
29 env!("CARGO_PKG_VERSION")
30}
31
32pub fn agent_is_older(agent_version: &str) -> bool {
35 match (
36 parse_version(agent_version),
37 parse_version(target_version()),
38 ) {
39 (Some(agent), Some(ours)) => agent < ours,
40 _ => false,
41 }
42}
43
44pub fn version_at_least(version: &str, minimum: &str) -> bool {
47 match (parse_version(version), parse_version(minimum)) {
48 (Some(version), Some(minimum)) => version >= minimum,
49 _ => false,
50 }
51}
52
53pub fn is_newer(version: &str) -> bool {
55 match (parse_version(version), parse_version(target_version())) {
56 (Some(other), Some(ours)) => other > ours,
57 _ => false,
58 }
59}
60
61fn parse_version(version: &str) -> Option<Vec<u64>> {
63 let core = version.split(['-', '+']).next()?;
64 core.split('.').map(|part| part.parse().ok()).collect()
65}
66
67fn agent_binary(agent_command: &str) -> Option<&str> {
70 agent_command
71 .split_whitespace()
72 .next()
73 .filter(|program| program.contains('/'))
74}
75
76fn shell_quote(text: &str) -> String {
78 format!("'{}'", text.replace('\'', r"'\''"))
79}
80
81pub fn update_script(version: &str, agent_command: &str) -> String {
84 script(version, agent_binary(agent_command))
85}
86
87pub fn self_update_script(version: &str, exe: &Path) -> String {
90 script(version, Some(&exe.to_string_lossy()))
91}
92
93fn script(version: &str, binary: Option<&str>) -> String {
95 let tag = shell_quote(&format!("v{version}"));
96 let url = shell_quote(&format!("{RELEASES_URL}/v{version}/install.sh"));
97 let find = match binary {
99 Some(path) => match path.strip_prefix("~/") {
100 Some(rest) => format!("bin=\"$HOME\"/{}", shell_quote(rest)),
101 None => format!("bin={}", shell_quote(path)),
102 },
103 None => "bin=$(command -v rigtop 2>/dev/null || true)\n\
106 [ -n \"$bin\" ] || [ ! -x \"$HOME/.local/bin/rigtop\" ] || bin=\"$HOME/.local/bin/rigtop\"\n\
107 [ -n \"$bin\" ] || [ ! -x \"$HOME/.cargo/bin/rigtop\" ] || bin=\"$HOME/.cargo/bin/rigtop\""
108 .to_owned(),
109 };
110 format!(
111 r#"set -eu
112{find}
113if [ -z "$bin" ] || [ ! -e "$bin" ]; then
114 echo "rigtop update: rigtop not found on this host" >&2
115 exit 1
116fi
117case "$bin" in
118 */Cellar/*)
119 echo "rigtop update: $bin was installed with Homebrew; update it with: brew upgrade rigtop" >&2
120 exit 1
121 ;;
122 */.cargo/bin/*)
123 echo "rigtop update: $bin was installed with cargo; update it with: cargo install $(basename "$bin") --version {version} --locked" >&2
124 exit 1
125 ;;
126esac
127dir=$(dirname "$bin")
128if [ ! -w "$dir" ]; then
129 echo "rigtop update: $dir is not writable (a package manager's copy?); update it with the package manager or the .deb of v{version}" >&2
130 exit 1
131fi
132# Keep rigtop-gui only where it was installed.
133gui=--tui-only
134[ ! -x "$dir/rigtop-gui" ] || gui=
135script=$(mktemp)
136trap 'rm -f "$script"' EXIT
137if command -v curl >/dev/null 2>&1; then
138 curl -fsSL --retry 3 {url} -o "$script"
139elif command -v wget >/dev/null 2>&1; then
140 wget -q -O "$script" {url}
141else
142 echo "rigtop update: needs curl or wget" >&2
143 exit 1
144fi
145sh "$script" --version {tag} --bin-dir "$dir" $gui
146"#
147 )
148}
149
150pub fn unsupported_reason(os: &str) -> Option<String> {
153 os.starts_with("Windows").then(|| {
154 "on Windows, update rigtop there with `scoop update rigtop`, or with the zip \
155 from the releases page"
156 .to_owned()
157 })
158}
159
160pub fn self_update_unsupported() -> Option<String> {
163 cfg!(windows).then(|| {
164 "on Windows, quit rigtop and run `scoop update rigtop`, or replace it with the \
165 zip from the releases page"
166 .to_owned()
167 })
168}
169
170pub fn describe_self(version: &str) -> String {
172 format!(
173 "Update rigtop from {ours} to {version}? This downloads the v{version} release \
174 from GitHub, checks it and replaces the rigtop you are running (when it is in a \
175 directory you can write to; copies from cargo or a package manager are left \
176 alone). The new version runs after a restart.",
177 ours = target_version()
178 )
179}
180
181pub fn describe(host: &str, agent_version: &str) -> String {
183 format!(
184 "Update rigtop on {host} from {agent_version} to {version}? Over SSH, this \
185 downloads the v{version} release from GitHub, checks it and replaces the \
186 rigtop the agent runs (when it is in a directory you can write to; copies \
187 from cargo or a package manager are left alone). The connection then restarts.",
188 version = target_version()
189 )
190}
191
192pub fn updatable(agent_version: &str, os: &str) -> bool {
195 agent_is_older(agent_version) && unsupported_reason(os).is_none()
196}
197
198pub fn describe_many(hosts: &[(String, String)]) -> String {
201 let list: Vec<String> = hosts
202 .iter()
203 .map(|(name, version)| format!("{name} ({version})"))
204 .collect();
205 format!(
206 "Update rigtop on {} to {version}? Over SSH, each downloads the v{version} \
207 release from GitHub, checks it and replaces the rigtop its agent runs (when \
208 it is in a directory you can write to). Each connection restarts when done.",
209 list.join(", "),
210 version = target_version()
211 )
212}
213
214pub struct UpdateJob {
216 result: Receiver<Result<String, String>>,
217}
218
219impl UpdateJob {
220 pub fn start(target: &RemoteTarget) -> Self {
222 let agent_command = target.args.last().cloned().unwrap_or_default();
223 let script = update_script(target_version(), &agent_command);
224 let mut args = target.args.clone();
226 if let Some(last) = args.last_mut() {
227 *last = "sh -s".to_owned();
228 }
229 Self::spawn(target.program.clone(), args, script)
230 }
231
232 pub fn start_self(version: &str, exe: &Path) -> Self {
236 let script = self_update_script(version, exe);
237 Self::spawn("sh".to_owned(), vec!["-s".to_owned()], script)
238 }
239
240 fn spawn(program: String, args: Vec<String>, script: String) -> Self {
241 let (tx, result) = mpsc::channel();
242 thread::Builder::new()
243 .name("rigtop-update".to_owned())
244 .spawn(move || {
245 let result = run_script(&program, &args, &script).map(|line| {
246 line.strip_prefix("rigtop install: done: ")
248 .map_or(line.clone(), str::to_owned)
249 });
250 let _ = tx.send(result);
251 })
252 .expect("spawn update thread");
253 Self { result }
254 }
255
256 pub fn poll(&self) -> Option<Result<String, String>> {
259 self.result.try_recv().ok()
260 }
261}
262
263pub const DEFAULT_CHECK_INTERVAL: Duration = Duration::from_secs(3600);
265
266pub struct ReleaseChecker {
268 interval: Duration,
269 next_check: Instant,
270 running: Option<Receiver<Result<String, String>>>,
271 newer: Option<String>,
273 fetch: fn() -> Result<String, String>,
274}
275
276impl ReleaseChecker {
277 pub fn new(interval: Duration) -> Self {
279 Self::with_fetch(interval, latest_release)
280 }
281
282 fn with_fetch(interval: Duration, fetch: fn() -> Result<String, String>) -> Self {
283 Self {
284 interval,
285 next_check: Instant::now(),
286 running: None,
287 newer: None,
288 fetch,
289 }
290 }
291
292 pub fn poll(&mut self) -> Option<&str> {
295 if let Some(running) = &self.running
296 && let Ok(result) = running.try_recv()
297 {
298 self.running = None;
299 if let Ok(version) = result
301 && is_newer(&version)
302 {
303 self.newer = Some(version);
304 }
305 }
306 if self.running.is_none() && Instant::now() >= self.next_check {
307 self.next_check = Instant::now() + self.interval;
308 let (tx, rx) = mpsc::channel();
309 let fetch = self.fetch;
310 let started = thread::Builder::new()
311 .name("rigtop-release-check".to_owned())
312 .spawn(move || {
313 let _ = tx.send(fetch());
314 });
315 if started.is_ok() {
316 self.running = Some(rx);
317 }
318 }
319 self.newer.as_deref()
320 }
321}
322
323pub fn latest_release() -> Result<String, String> {
326 let null = if cfg!(windows) { "NUL" } else { "/dev/null" };
327 let mut command = Command::new("curl");
328 command
329 .args([
330 "-fsSL",
331 "--max-time",
332 "30",
333 "-o",
334 null,
335 "-w",
336 "%{url_effective}",
337 ])
338 .arg(LATEST_URL)
339 .stdin(Stdio::null())
340 .stderr(Stdio::null());
341 #[cfg(windows)]
342 {
343 use std::os::windows::process::CommandExt;
344 command.creation_flags(0x0800_0000);
346 }
347 let output = command
348 .output()
349 .map_err(|err| format!("cannot run curl: {err}"))?;
350 if !output.status.success() {
351 return Err(format!("curl exited with {}", output.status));
352 }
353 version_from_release_url(&String::from_utf8_lossy(&output.stdout))
354 .ok_or_else(|| "no release found".to_owned())
355}
356
357fn version_from_release_url(url: &str) -> Option<String> {
359 let tag = url.trim().rsplit_once("/tag/")?.1;
360 let version = tag.strip_prefix('v')?;
361 parse_version(version).map(|_| version.to_owned())
362}
363
364fn run_script(program: &str, args: &[String], script: &str) -> Result<String, String> {
367 let mut command = Command::new(program);
368 command
369 .args(args)
370 .stdin(Stdio::piped())
371 .stdout(Stdio::piped())
372 .stderr(Stdio::piped());
373 #[cfg(windows)]
374 {
375 use std::os::windows::process::CommandExt;
376 command.creation_flags(0x0800_0000);
378 }
379 let mut child = command
380 .spawn()
381 .map_err(|err| format!("cannot start {program}: {err}"))?;
382 if let Some(mut stdin) = child.stdin.take() {
383 let _ = stdin.write_all(script.as_bytes());
385 }
386 let output = child
387 .wait_with_output()
388 .map_err(|err| format!("{program} failed: {err}"))?;
389 let last_line = |bytes: &[u8]| {
390 String::from_utf8_lossy(bytes)
391 .lines()
392 .rev()
393 .map(str::trim)
394 .find(|line| !line.is_empty())
395 .map(str::to_owned)
396 };
397 if output.status.success() {
398 Ok(last_line(&output.stdout).unwrap_or_else(|| "done".to_owned()))
399 } else {
400 Err(last_line(&output.stderr)
401 .or_else(|| last_line(&output.stdout))
402 .unwrap_or_else(|| format!("{program} exited with {}", output.status)))
403 }
404}
405
406#[cfg(test)]
407mod tests {
408 use super::*;
409
410 #[test]
411 fn versions_compare_by_number() {
412 assert_eq!(parse_version("0.10.1"), Some(vec![0, 10, 1]));
413 assert_eq!(parse_version("1.2.0-rc.1"), Some(vec![1, 2, 0]));
414 assert_eq!(parse_version(""), None);
415 assert!(agent_is_older("0.0.9"));
416 assert!(!agent_is_older(target_version()));
417 assert!(!agent_is_older("999.0.0"));
418 assert!(!agent_is_older(""), "unknown versions are not offered");
419 assert!(updatable("0.0.9", "Linux (Ubuntu 26.04)"));
420 assert!(!updatable("0.0.9", "Windows 11 Pro"), "Scoop updates those");
421 assert!(!updatable(target_version(), "Linux"));
422 assert!(describe_many(&[("nas".to_owned(), "0.0.9".to_owned())]).contains("nas (0.0.9)"));
423 assert!(version_at_least("0.21.0", "0.21.0"));
424 assert!(version_at_least("0.21.1", "0.21.0"));
425 assert!(!version_at_least("0.20.0", "0.21.0"));
426 assert!(!version_at_least("", "0.21.0"));
427 }
428
429 #[test]
430 fn newer_releases_are_found() {
431 assert_eq!(
432 version_from_release_url("https://github.com/akivajp/rigtop/releases/tag/v0.12.1\n"),
433 Some("0.12.1".to_owned())
434 );
435 assert_eq!(
436 version_from_release_url("https://github.com/akivajp/rigtop/releases"),
437 None
438 );
439 assert!(is_newer("999.0.0"));
440 assert!(!is_newer(target_version()));
441
442 let mut checker =
443 ReleaseChecker::with_fetch(Duration::from_secs(3600), || Ok("999.0.0".to_owned()));
444 let mut found = None;
446 for _ in 0..100 {
447 if let Some(version) = checker.poll() {
448 found = Some(version.to_owned());
449 break;
450 }
451 thread::sleep(Duration::from_millis(10));
452 }
453 assert_eq!(found.as_deref(), Some("999.0.0"));
454
455 let mut checker = ReleaseChecker::with_fetch(Duration::from_secs(3600), || {
456 Ok(target_version().to_owned())
457 });
458 checker.poll();
459 thread::sleep(Duration::from_millis(50));
460 assert!(checker.poll().is_none(), "the same version is not news");
461 }
462
463 #[test]
465 #[ignore]
466 fn latest_release_from_github() {
467 let version = latest_release().unwrap();
468 assert!(parse_version(&version).is_some(), "{version}");
469 }
470
471 #[test]
472 fn self_update_script_targets_the_running_program() {
473 let script = self_update_script("1.2.3", Path::new("/home/me/.local/bin/rigtop-gui"));
474 assert!(script.contains("bin='/home/me/.local/bin/rigtop-gui'"));
475 }
476
477 #[test]
478 fn windows_hosts_get_directions_instead() {
479 assert!(unsupported_reason("Windows 11 Pro").is_some());
480 assert!(unsupported_reason("Linux (Ubuntu 24.04)").is_none());
481 }
482
483 #[test]
484 fn script_uses_the_agent_command_path() {
485 assert!(update_script("1.2.3", "rigtop --agent").contains("command -v rigtop"));
486 let script = update_script("1.2.3", "/opt/my rig/rigtop --agent");
487 assert!(script.contains("bin='/opt/my'"));
489 let script = update_script("1.2.3", "~/bin/rigtop --agent");
490 assert!(script.contains(r#"bin="$HOME"/'bin/rigtop'"#));
491 assert!(
492 script.contains(
493 "'https://github.com/akivajp/rigtop/releases/download/v1.2.3/install.sh'"
494 )
495 );
496 assert!(script.contains("--version 'v1.2.3'"));
497 }
498
499 #[cfg(unix)]
502 #[test]
503 fn script_installs_next_to_the_running_rigtop() {
504 use std::fs;
505 use std::os::unix::fs::PermissionsExt;
506
507 let tmp = tempfile::tempdir().unwrap();
508 let bin = tmp.path().join("bin");
509 let tools = tmp.path().join("tools");
510 fs::create_dir_all(&bin).unwrap();
511 fs::create_dir_all(&tools).unwrap();
512 let executable = |path: &std::path::Path, text: &str| {
513 fs::write(path, text).unwrap();
514 fs::set_permissions(path, fs::Permissions::from_mode(0o755)).unwrap();
515 };
516 executable(&bin.join("rigtop"), "#!/bin/sh\n");
517 executable(
519 &tools.join("curl"),
520 "#!/bin/sh\nwhile [ $# -gt 1 ]; do [ \"$1\" = -o ] && out=$2; shift; done\n\
521 printf 'echo installed \"$@\"\\n' > \"$out\"\n",
522 );
523 let path = format!("{}:{}:/usr/bin:/bin", bin.display(), tools.display());
524 let run = |script: &str| {
525 run_script(
526 "env",
527 &[format!("PATH={path}"), "sh".to_owned(), "-s".to_owned()],
528 script,
529 )
530 };
531
532 let output = run(&update_script("1.2.3", "rigtop --agent")).unwrap();
533 assert_eq!(
534 output,
535 format!(
536 "installed --version v1.2.3 --bin-dir {} --tui-only",
537 bin.display()
538 )
539 );
540
541 let home = tmp.path().join("home");
543 let local = home.join(".local/bin");
544 fs::create_dir_all(&local).unwrap();
545 executable(&local.join("rigtop"), "#!/bin/sh\n");
546 let output = run_script(
547 "env",
548 &[
549 format!("HOME={}", home.display()),
550 format!("PATH={}:/usr/bin:/bin", tools.display()),
551 "sh".to_owned(),
552 "-s".to_owned(),
553 ],
554 &update_script("1.2.3", "rigtop --agent"),
555 )
556 .unwrap();
557 assert!(
558 output.contains(&format!("--bin-dir {}", local.display())),
559 "{output}"
560 );
561
562 fs::set_permissions(&bin, fs::Permissions::from_mode(0o555)).unwrap();
564 let result = run(&update_script("1.2.3", "rigtop --agent"));
565 fs::set_permissions(&bin, fs::Permissions::from_mode(0o755)).unwrap();
566 if let Err(error) = result {
568 assert!(error.contains("is not writable"), "{error}");
569 }
570
571 let cargo = tmp.path().join(".cargo/bin");
573 fs::create_dir_all(&cargo).unwrap();
574 executable(&cargo.join("rigtop"), "#!/bin/sh\n");
575 let command = format!("{}/rigtop --agent", cargo.display());
576 let error = run(&update_script("1.2.3", &command)).unwrap_err();
577 assert!(
578 error.contains("cargo install rigtop --version 1.2.3"),
579 "{error}"
580 );
581
582 let cellar = tmp.path().join("homebrew/Cellar/rigtop/0.15.0/bin");
584 fs::create_dir_all(&cellar).unwrap();
585 executable(&cellar.join("rigtop"), "#!/bin/sh\n");
586 let error = run(&self_update_script("1.2.3", &cellar.join("rigtop"))).unwrap_err();
587 assert!(error.contains("brew upgrade rigtop"), "{error}");
588
589 executable(&bin.join("rigtop-gui"), "#!/bin/sh\n");
591 let output = run(&self_update_script("1.2.3", &bin.join("rigtop-gui"))).unwrap();
592 assert_eq!(
593 output,
594 format!("installed --version v1.2.3 --bin-dir {}", bin.display())
595 );
596
597 let error = run(&update_script("1.2.3", "/nowhere/rigtop --agent")).unwrap_err();
598 assert!(error.contains("not found"), "{error}");
599 }
600}