main.rs
1 mod autoproviders { 2 include!(concat!(env!("OUT_DIR"), "/autoproviders.rs")); 3 } 4 mod custom; 5 mod profile; 6 7 use anyhow::Result; 8 use chrono::Local; 9 use clap::{Parser, Subcommand}; 10 use console::{strip_ansi_codes, style, Term}; 11 use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle}; 12 use profile::{styled, Profile}; 13 #[cfg(unix)] 14 use std::sync::OnceLock; 15 use std::sync::atomic::{AtomicUsize, Ordering}; 16 use std::{ 17 process::Stdio, 18 sync::Arc, 19 time::{Duration, Instant}, 20 }; 21 use tokio::{ 22 fs::File, 23 io::{AsyncReadExt, AsyncWriteExt}, 24 process::Command, 25 sync::Mutex, 26 }; 27 28 #[cfg(windows)] 29 unsafe extern "system" { 30 fn SetThreadExecutionState(flags: u32) -> u32; 31 } 32 33 #[cfg(windows)] 34 const ES_CONTINUOUS: u32 = 0x80000000; 35 #[cfg(windows)] 36 const ES_SYSTEM_REQUIRED: u32 = 0x00000001; 37 #[cfg(windows)] 38 const ES_DISPLAY_REQUIRED: u32 = 0x00000002; 39 40 #[cfg(unix)] 41 static ORIGINAL_TERMIOS: OnceLock<libc::termios> = OnceLock::new(); 42 43 static CTRLC_COUNT: AtomicUsize = AtomicUsize::new(0); 44 45 #[cfg(unix)] 46 fn suppress_stdin() { 47 unsafe { 48 let fd: i32 = 0; 49 let mut original: libc::termios = std::mem::zeroed(); 50 if libc::tcgetattr(fd, &mut original) == 0 { 51 ORIGINAL_TERMIOS.set(original).ok(); 52 let mut raw = original; 53 raw.c_lflag &= !(libc::ECHO | libc::ICANON | libc::ISIG); 54 raw.c_cc[libc::VMIN] = 1; 55 raw.c_cc[libc::VTIME] = 0; 56 libc::tcsetattr(fd, libc::TCSANOW, &raw); 57 } 58 } 59 } 60 61 #[cfg(unix)] 62 fn restore_terminal() { 63 if let Some(original) = ORIGINAL_TERMIOS.get() { 64 unsafe { libc::tcsetattr(0, libc::TCSANOW, original); } 65 } 66 } 67 68 pub trait Provider: Send + Sync { 69 fn name(&self) -> &str; 70 fn is_available(&self) -> bool; 71 fn get_command(&self) -> String; 72 fn needs_root(&self) -> bool { true } 73 fn parse_progress(&self, _line: &str) -> Option<String> { None } 74 fn parse_summary(&self, _log: &str) -> UpdateSummary { UpdateSummary::default() } 75 } 76 77 #[derive(Debug, Clone, Default)] 78 pub struct UpdateSummary { 79 pub upgraded: usize, 80 pub installed: usize, 81 pub removed: usize, 82 pub packages: Vec<String>, 83 } 84 85 #[derive(Parser)] 86 #[command(name = "bupdate", version, about = "Universal package updater")] 87 #[command(after_help = concat!( 88 "\x1b[36mExamples:\x1b[0m\n", 89 " bupdate Run all available providers\n", 90 " bupdate --shutdown Update and shutdown when done\n", 91 " bupdate --suspend Update and suspend when done\n", 92 " bupdate --lock Update and lock screen when done\n", 93 " bupdate --list Show detected providers\n", 94 " bupdate -i arch Skip a provider\n", 95 " bupdate -i arch -i flatpak Skip multiple providers\n", 96 " bupdate profile init Create default profile\n", 97 " bupdate profile show Show current profile", 98 ))] 99 struct Cli { 100 #[arg(long = "shutdown", help = "Shutdown the system after updates complete")] 101 shutdown: bool, 102 103 #[arg(long = "suspend", help = "Suspend the system after updates complete")] 104 suspend: bool, 105 106 #[arg(long = "lock", help = "Lock the screen after updates complete")] 107 lock: bool, 108 109 #[arg(short = 'l', long = "list", help = "List detected active providers and exit")] 110 list: bool, 111 112 #[arg(short = 'i', long = "ignore", value_name = "PROVIDER", help = "Skip specific providers")] 113 ignore: Vec<String>, 114 115 #[command(subcommand)] 116 command: Option<Commands>, 117 } 118 119 #[derive(Subcommand)] 120 enum Commands { 121 /// Manage bupdate profile (~/.config/bupdate/profile.toml) 122 Profile { 123 #[command(subcommand)] 124 action: ProfileAction, 125 }, 126 } 127 128 #[derive(Subcommand)] 129 enum ProfileAction { 130 /// Create a default profile configuration file 131 Init, 132 /// Show current profile settings 133 Show, 134 /// Print the profile file path 135 Path, 136 } 137 138 fn bin_exists(name: &str) -> bool { 139 std::process::Command::new("which") 140 .arg(name) 141 .stdout(Stdio::null()) 142 .stderr(Stdio::null()) 143 .status() 144 .map(|s| s.success()) 145 .unwrap_or(false) 146 } 147 148 async fn try_sleep_inhibit() -> Option<tokio::process::Child> { 149 let strategies: &[&[&str]] = &[ 150 &["systemd-inhibit", "--what=sleep:idle", "--who=bupdate", "--why=System_Update", "sleep", "infinity"], 151 &["caffeine"], 152 &["sh", "-c", "while true; do sleep 60; done"], 153 ]; 154 for args in strategies { 155 if bin_exists(args[0]) { 156 if let Ok(child) = Command::new(args[0]).args(&args[1..]).spawn() { 157 return Some(child); 158 } 159 } 160 } 161 None 162 } 163 164 async fn run_first_available(strategies: &[&[&str]]) -> Result<()> { 165 for args in strategies { 166 if bin_exists(args[0]) { 167 let use_sudo = matches!(args[0], "shutdown" | "poweroff" | "halt" | "pm-suspend"); 168 if use_sudo { 169 Command::new("sudo") 170 .args(*args) 171 .spawn()? 172 .wait() 173 .await?; 174 } else { 175 Command::new(args[0]) 176 .args(&args[1..]) 177 .spawn()? 178 .wait() 179 .await?; 180 } 181 return Ok(()); 182 } 183 } 184 anyhow::bail!("No supported power management tool found") 185 } 186 187 #[tokio::main] 188 async fn main() -> Result<()> { 189 let cli = Cli::parse(); 190 191 if let Some(Commands::Profile { action }) = &cli.command { 192 match action { 193 ProfileAction::Init => { 194 let path = Profile::init()?; 195 println!("{} {}", style("✓ Profile created:").green().bold(), path.display()); 196 let pdir = custom::init_providers_dir()?; 197 println!("{} {}", style("✓ Providers dir:").green().bold(), pdir.display()); 198 return Ok(()); 199 } 200 ProfileAction::Show => { 201 let profile = Profile::load()?; 202 profile.show()?; 203 return Ok(()); 204 } 205 ProfileAction::Path => { 206 println!("{}", profile::profile_path()?.display()); 207 return Ok(()); 208 } 209 } 210 } 211 212 let profile = Arc::new(Profile::load()?); 213 let sym = &profile.symbols; 214 let sty = &profile.styles; 215 let msg = &profile.messages; 216 217 let mut all_ignore: Vec<String> = cli.ignore.clone(); 218 all_ignore.extend(profile.providers.ignore.iter().cloned()); 219 220 let mut all_providers: Vec<Box<dyn Provider>> = autoproviders::get_all_active(); 221 all_providers.extend(custom::load_custom_providers()); 222 223 let active_providers: Vec<Box<dyn Provider>> = all_providers 224 .into_iter() 225 .filter(|p| { 226 let name = p.name(); 227 if !profile.providers.only.is_empty() { 228 return profile.providers.only.iter().any(|o| o.eq_ignore_ascii_case(name)); 229 } 230 !all_ignore.iter().any(|ig| ig.eq_ignore_ascii_case(name)) 231 }) 232 .collect(); 233 234 if cli.list { 235 if active_providers.is_empty() { 236 println!("{}", styled(&msg.no_providers_list, &sty.info)); 237 } else { 238 println!("{}", styled(&msg.list_header, &sty.header)); 239 for p in &active_providers { 240 println!(" {} {}", styled(&sym.bullet, &sty.bullet), styled(p.name(), &sty.provider_name)); 241 } 242 } 243 return Ok(()); 244 } 245 246 if active_providers.is_empty() { 247 println!("\n{}\n", styled(&msg.no_providers, &sty.warning)); 248 return Ok(()); 249 } 250 251 let any_needs_root = active_providers.iter().any(|p| p.needs_root()); 252 253 if any_needs_root && !cfg!(windows) { 254 if !std::process::Command::new("sudo") 255 .arg("-v") 256 .status()? 257 .success() 258 { 259 eprintln!("{}", styled(&format!("{} {}", sym.failure, msg.sudo_fail), &sty.error)); 260 std::process::exit(1); 261 } 262 263 tokio::spawn(async { 264 loop { 265 tokio::time::sleep(Duration::from_secs(60)).await; 266 let _ = Command::new("sudo").args(["-n", "true"]).output().await; 267 } 268 }); 269 } 270 271 let now = Local::now(); 272 let run_id = now.format("%Y-%m-%d_%H-%M-%S").to_string(); 273 let configured_log_dir = std::path::PathBuf::from(&profile.logging.log_dir); 274 let preferred_log_dir = configured_log_dir.join(&run_id); 275 276 if cfg!(windows) { 277 let _ = std::fs::create_dir_all(&preferred_log_dir); 278 } else { 279 Command::new("sudo") 280 .args(["mkdir", "-p", &preferred_log_dir.to_string_lossy()]) 281 .output() 282 .await?; 283 Command::new("sudo") 284 .args(["chmod", "777", &preferred_log_dir.to_string_lossy()]) 285 .output() 286 .await?; 287 } 288 let log_dir = preferred_log_dir.to_string_lossy().into_owned(); 289 290 #[cfg(windows)] 291 unsafe { SetThreadExecutionState(ES_CONTINUOUS | ES_SYSTEM_REQUIRED | ES_DISPLAY_REQUIRED); } 292 293 let sleep_lock = Arc::new(Mutex::new(if cfg!(windows) { 294 None 295 } else { 296 try_sleep_inhibit().await 297 })); 298 299 300 #[cfg(unix)] 301 suppress_stdin(); 302 303 let started = Instant::now(); 304 let m = MultiProgress::with_draw_target(ProgressDrawTarget::stdout_with_hz(15)); 305 306 let total = active_providers.len(); 307 println!( 308 "\n{}", 309 styled( 310 &format!("{} {} {}", sym.border, msg.header, sym.border), 311 &sty.header, 312 ) 313 ); 314 let subtitle_text = msg 315 .subtitle 316 .replace("{count}", &total.to_string()) 317 .replace("{s}", if total == 1 { "" } else { "s" }); 318 println!("{}\n", styled(&subtitle_text, &sty.subtitle)); 319 320 let tick_strs = profile.tick_strings(); 321 let tick_refs: Vec<&str> = tick_strs.iter().map(|s| s.as_str()).collect(); 322 let bar_style = ProgressStyle::with_template(&profile.format.progress_bar)? 323 .tick_strings(&tick_refs); 324 325 let tick_ms = profile.spinner.tick_ms; 326 327 let log_dir_arc = Arc::new(log_dir.clone()); 328 let profile_arc = Arc::clone(&profile); 329 let mut tasks = vec![]; 330 331 for p in active_providers { 332 let pb = m.add(ProgressBar::new_spinner()); 333 pb.set_style(bar_style.clone()); 334 pb.enable_steady_tick(Duration::from_millis(tick_ms)); 335 336 let p_name = p.name().to_string(); 337 pb.set_prefix(p_name.clone()); 338 339 let log_path = format!("{}/{}.log", log_dir_arc, p_name); 340 let prof = Arc::clone(&profile_arc); 341 let p_arc: Arc<dyn Provider> = Arc::from(p); 342 343 tasks.push(tokio::spawn(async move { 344 let Ok(mut file) = File::create(&log_path).await else { 345 return (p_name, false, UpdateSummary::default()); 346 }; 347 348 let use_sudo = p_arc.needs_root() && !cfg!(windows); 349 let mut cmd = if cfg!(windows) { 350 let mut c = Command::new("cmd"); 351 c.args(["/C", &p_arc.get_command()]); 352 c 353 } else if use_sudo { 354 let mut c = Command::new("sudo"); 355 c.args(["sh", "-c", &format!("LC_ALL=C {} 2>&1", p_arc.get_command())]); 356 c 357 } else { 358 let mut c = Command::new("sh"); 359 c.args(["-c", &format!("LC_ALL=C {} 2>&1", p_arc.get_command())]); 360 c 361 }; 362 if cfg!(windows) { 363 cmd.current_dir("C:\\").stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()); 364 } else { 365 cmd.current_dir("/").stdin(Stdio::null()).stdout(Stdio::piped()); 366 } 367 368 let Ok(mut child) = cmd.spawn() else { 369 return (p_name, false, UpdateSummary::default()); 370 }; 371 372 let Some(mut stdout) = child.stdout.take() else { 373 return (p_name, false, UpdateSummary::default()); 374 }; 375 let max_len = (Term::stdout().size().1 as usize).saturating_sub(44).max(20); 376 let mut buf = [0u8; 4096]; 377 let mut leftover = String::new(); 378 let mut full_log = String::new(); 379 let mut last_prog: Option<String> = None; 380 381 loop { 382 let n = match stdout.read(&mut buf).await { 383 Ok(0) | Err(_) => break, 384 Ok(n) => n, 385 }; 386 let chunk = String::from_utf8_lossy(&buf[..n]); 387 leftover.push_str(&chunk); 388 389 while let Some(pos) = leftover.find(|c: char| c == '\n' || c == '\r') { 390 let segment: String = leftover.drain(..pos).collect(); 391 while leftover.starts_with('\n') || leftover.starts_with('\r') { 392 leftover.drain(..1); 393 } 394 395 let _ = file.write_all(format!("{}\n", segment).as_bytes()).await; 396 397 let stripped = strip_ansi_codes(&segment).to_string(); 398 let sanitized: String = stripped 399 .chars() 400 .filter(|c| !c.is_control() && *c != '\x7f' && *c != '\u{feff}') 401 .filter(|c| !matches!(*c, '\x00'..='\x1f' | '\u{80}'..='\u{9f}')) 402 .collect(); 403 404 full_log.push_str(&sanitized); 405 full_log.push('\n'); 406 407 let clean: String = sanitized.trim().chars().take(max_len).collect(); 408 409 if clean.is_empty() { 410 continue; 411 } 412 413 let full_line = sanitized.trim(); 414 if let Some(prog) = p_arc.parse_progress(full_line) { 415 last_prog = Some(prog); 416 } 417 418 let msg = if let Some(ref prog) = last_prog { 419 prof.format 420 .progress_line 421 .replace("{progress}", &styled(prog, &prof.styles.progress)) 422 .replace("{arrow}", &styled(&prof.symbols.arrow, &prof.styles.arrow)) 423 .replace("{line}", &clean) 424 } else { 425 clean 426 }; 427 let safe_msg: String = msg 428 .chars() 429 .filter(|c| !matches!(*c, '\n' | '\r' | '\t' | '\x0b' | '\x0c' | '\x08' | '\x7f')) 430 .collect(); 431 pb.set_message(safe_msg); 432 } 433 } 434 435 let ok = child.wait().await.map(|s| s.success()).unwrap_or(false); 436 let summary = p_arc.parse_summary(&full_log); 437 438 let mut parts = vec![]; 439 if summary.upgraded > 0 { parts.push(format!("{} upgraded", summary.upgraded)); } 440 if summary.installed > 0 { parts.push(format!("{} installed", summary.installed)); } 441 if summary.removed > 0 { parts.push(format!("{} removed", summary.removed)); } 442 let detail = if parts.is_empty() { 443 String::new() 444 } else { 445 format!(" ({})", parts.join(", ")) 446 }; 447 448 if ok { 449 pb.finish_with_message(styled( 450 &format!("{} {}{}", prof.symbols.success, prof.messages.success_text, detail), 451 &prof.styles.success, 452 )); 453 } else { 454 pb.finish_with_message(styled( 455 &format!("{} {}{}", prof.symbols.failure, prof.messages.failure_text, detail), 456 &prof.styles.error, 457 )); 458 } 459 (p_name, ok, summary) 460 })); 461 } 462 463 let status_bar = m.add(ProgressBar::new_spinner()); 464 status_bar.set_style(ProgressStyle::with_template("{msg}").unwrap()); 465 status_bar.set_message(""); 466 467 let sb = status_bar.clone(); 468 let profile_ctrlc = Arc::clone(&profile); 469 std::thread::spawn(move || { 470 use std::io::Read; 471 let msg = &profile_ctrlc.messages; 472 let sty = &profile_ctrlc.styles; 473 let mut buf = [0u8; 256]; 474 loop { 475 match std::io::stdin().read(&mut buf) { 476 Ok(0) | Err(_) => break, 477 Ok(n) => { 478 for &b in &buf[..n] { 479 if b == 0x03 { 480 let count = CTRLC_COUNT.fetch_add(1, Ordering::SeqCst) + 1; 481 if count == 1 { 482 sb.set_message(format!("\n{}", styled(&msg.interrupt_warn, &sty.warning))); 483 } else { 484 sb.set_message(format!("\n{}", styled(&msg.interrupt_force, &sty.warning))); 485 #[cfg(unix)] 486 restore_terminal(); 487 std::process::exit(130); 488 } 489 } 490 } 491 } 492 } 493 } 494 }); 495 496 let mut total_upgraded = 0usize; 497 let mut total_installed = 0usize; 498 let mut total_removed = 0usize; 499 let mut ok_count = 0usize; 500 let mut fail_count = 0usize; 501 let mut provider_results: Vec<(String, UpdateSummary)> = vec![]; 502 503 for t in tasks { 504 if let Ok((name, ok, summary)) = t.await { 505 if ok { ok_count += 1; } else { fail_count += 1; } 506 total_upgraded += summary.upgraded; 507 total_installed += summary.installed; 508 total_removed += summary.removed; 509 if summary.upgraded > 0 || summary.installed > 0 || summary.removed > 0 { 510 provider_results.push((name, summary)); 511 } 512 } 513 } 514 515 status_bar.finish_and_clear(); 516 517 let elapsed = started.elapsed(); 518 println!( 519 "\n\n{}", 520 styled( 521 &format!("{} {} {}", sym.border, msg.complete_header, sym.border), 522 &sty.header, 523 ) 524 ); 525 526 let total_pkgs = total_upgraded + total_installed + total_removed; 527 if total_pkgs > 0 || fail_count > 0 { 528 let mut totals = vec![]; 529 totals.push(format!("{}/{} ok", ok_count, ok_count + fail_count)); 530 if total_upgraded > 0 { totals.push(format!("{} upgraded", total_upgraded)); } 531 if total_installed > 0 { totals.push(format!("{} installed", total_installed)); } 532 if total_removed > 0 { totals.push(format!("{} removed", total_removed)); } 533 if fail_count > 0 { totals.push(styled(&format!("{} failed", fail_count), &sty.error)); } 534 println!( 535 " {} {}", 536 styled("Total:", &sty.info), 537 totals.join(&styled(" | ", &sty.info)), 538 ); 539 } 540 541 println!( 542 " {} {:.1}s", 543 styled(&msg.duration_label, &sty.info), 544 elapsed.as_secs_f64() 545 ); 546 println!( 547 " {} {}", 548 styled(&msg.logs_label, &sty.info), 549 styled(&log_dir, &sty.log_path) 550 ); 551 552 if !provider_results.is_empty() { 553 let term_w = Term::stdout().size().1 as usize; 554 println!(); 555 for (prov, s) in &provider_results { 556 let header = format!(" {} ", prov); 557 let header_styled = format!(" {} ", styled(prov, &sty.provider_name)); 558 let indent = " ".repeat(header.len()); 559 if !s.packages.is_empty() { 560 let mut line = header_styled.clone(); 561 let mut line_len = header.len(); 562 for (i, pkg) in s.packages.iter().enumerate() { 563 let sep = if i == 0 { "" } else { ", " }; 564 let addition = format!("{}{}", sep, pkg); 565 if i > 0 && line_len + addition.len() > term_w { 566 println!("{}", styled(&line, &sty.info)); 567 line = format!("{}{}", indent, pkg); 568 line_len = indent.len() + pkg.len(); 569 } else { 570 line.push_str(&styled(&addition, &sty.info)); 571 line_len += addition.len(); 572 } 573 } 574 println!("{}", line); 575 } else { 576 let mut parts = vec![]; 577 if s.upgraded > 0 { parts.push(format!("{} upgraded", s.upgraded)); } 578 if s.installed > 0 { parts.push(format!("{} installed", s.installed)); } 579 if s.removed > 0 { parts.push(format!("{} removed", s.removed)); } 580 println!( 581 "{}{}", 582 header_styled, 583 styled(&parts.join(", "), &sty.info), 584 ); 585 } 586 } 587 } 588 589 if let Some(mut child) = sleep_lock.lock().await.take() { 590 let _ = child.kill().await; 591 } 592 #[cfg(windows)] 593 unsafe { SetThreadExecutionState(ES_CONTINUOUS); } 594 595 let do_shutdown = cli.shutdown || profile.post_update.shutdown; 596 let do_suspend = cli.suspend || profile.post_update.suspend; 597 598 if do_shutdown { 599 println!("\n{}", styled(&msg.shutting_down, &sty.warning)); 600 if cfg!(windows) { 601 Command::new("shutdown") 602 .args(["/s", "/t", "0"]) 603 .spawn()? 604 .wait() 605 .await?; 606 } else { 607 run_first_available(&[ 608 &["shutdown", "now"], 609 &["poweroff"], 610 &["halt", "-p"], 611 &["loginctl", "poweroff"], 612 &["dbus-send", "--system", "--print-reply", 613 "--dest=org.freedesktop.login1", 614 "/org/freedesktop/login1", 615 "org.freedesktop.login1.Manager.PowerOff", 616 "boolean:true"], 617 ]).await?; 618 } 619 } else if do_suspend { 620 println!("\n{}", styled(&msg.suspending, &sty.warning)); 621 if cfg!(windows) { 622 Command::new("rundll32") 623 .args(["powrprof.dll,SetSuspendState", "0", "1", "0"]) 624 .spawn()? 625 .wait() 626 .await?; 627 } else { 628 run_first_available(&[ 629 &["systemctl", "suspend"], 630 &["loginctl", "suspend"], 631 &["zzz"], 632 &["pm-suspend"], 633 &["dbus-send", "--system", "--print-reply", 634 "--dest=org.freedesktop.login1", 635 "/org/freedesktop/login1", 636 "org.freedesktop.login1.Manager.Suspend", 637 "boolean:true"], 638 &["acpitool", "-s"], 639 ]).await?; 640 } 641 } 642 643 let do_lock = cli.lock || profile.post_update.lock; 644 if do_lock { 645 println!("\n{}", styled(&msg.locking, &sty.warning)); 646 if cfg!(windows) { 647 Command::new("rundll32") 648 .args(["user32.dll,LockWorkStation"]) 649 .spawn()? 650 .wait() 651 .await?; 652 } else { 653 run_first_available(&[ 654 &["loginctl", "lock-session"], 655 &["xdg-screensaver", "lock"], 656 &["gnome-screensaver-command", "-l"], 657 &["mate-screensaver-command", "-l"], 658 &["cinnamon-screensaver-command", "-l"], 659 &["xflock4"], 660 &["swaylock"], 661 &["i3lock"], 662 &["xscreensaver-command", "-lock"], 663 &["light-locker-command", "-l"], 664 &["dm-tool", "lock"], 665 &["dbus-send", "--type=method_call", 666 "--dest=org.freedesktop.ScreenSaver", 667 "/ScreenSaver", 668 "org.freedesktop.ScreenSaver.Lock"], 669 ]).await?; 670 } 671 } 672 673 #[cfg(unix)] 674 restore_terminal(); 675 676 Ok(()) 677 }