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