From 0f3785626f70c33d507da39e97cbbf5187dcc6d9 Mon Sep 17 00:00:00 2001 From: yukimemi Date: Thu, 7 May 2026 01:51:54 +0900 Subject: [PATCH] feat: migrate self-update logic to kaishin v0.2.4 Why: - To consolidate self-update logic into a universal library \kaishin\. - To improve code maintainability and standardize update banners. Changes (addressing reviews): - Refactored \updater::Checker\ to avoid persistent \ okio::runtime::Runtime\ to reduce resource overhead. - Propagated \ on_interactive\ flag to \kaishin::run_self_update\ to honor \ enri --non-interactive\. - Improved \AutoUpdateHandle\ to carry \cached_latest\ so banners can fallback to cached state on timeout. - Fixed brittle tests by removing dependency on persistent state. --- .kata/applied.toml | 2 +- Cargo.lock | 38 +++++- Cargo.toml | 16 +-- src/main.rs | 94 ++++++-------- src/updater.rs | 301 ++++++++------------------------------------- 5 files changed, 124 insertions(+), 327 deletions(-) diff --git a/.kata/applied.toml b/.kata/applied.toml index 5b88813..1829e68 100644 --- a/.kata/applied.toml +++ b/.kata/applied.toml @@ -1,5 +1,5 @@ preset = "github.com/yukimemi/pj-presets:rust-cli" -applied_at = "2026-05-05T16:39:36.2177367Z" +applied_at = "2026-05-07T11:30:27.3001733Z" [[templates]] source = "github.com/yukimemi/pj-base" diff --git a/Cargo.lock b/Cargo.lock index 7d67349..101aa04 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1317,6 +1317,25 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kaishin" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa6d81b71435b45fa1fd6832090ef59e3601fd01518336daccbdff9e71cfce3" +dependencies = [ + "anyhow", + "console", + "dirs", + "humantime", + "reqwest", + "self_update", + "semver", + "serde", + "serde_json", + "tempfile", + "tokio", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1912,18 +1931,17 @@ dependencies = [ "clap", "clap_complete", "dirs", - "humantime", "inquire", + "kaishin", "owo-colors", "pretty_assertions", - "reqwest", - "self_update", "semver", "serde", "serde_json", "tempfile", "teravars", "thiserror", + "tokio", "toml", "tracing", "tracing-subscriber", @@ -2589,11 +2607,25 @@ dependencies = [ "bytes", "libc", "mio", + "parking_lot", "pin-project-lite", + "signal-hook-registry", "socket2", + "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tokio-rustls" version = "0.26.4" diff --git a/Cargo.toml b/Cargo.toml index bee1999..eeff138 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,22 +26,10 @@ dirs = "6" whoami = "2.1.2" serde_json = "1.0.149" owo-colors = "4.3.0" +kaishin = "0.2" +tokio = { version = "1", features = ["rt", "macros"] } semver = "1.0.28" tempfile = "3.27.0" -humantime = "2.3.0" -reqwest = { version = "0.13.3", default-features = false, features = [ - "blocking", - "json", - "rustls", -] } -self_update = { version = "0.44", default-features = false, features = [ - "reqwest", - "rustls", - "compression-flate2", - "archive-tar", - "archive-zip", - "compression-zip-deflate", -] } [dev-dependencies] pretty_assertions = "1.4.1" diff --git a/src/main.rs b/src/main.rs index bbad279..0546767 100644 --- a/src/main.rs +++ b/src/main.rs @@ -266,13 +266,15 @@ fn main() -> Result<()> { enum AutoUpdateHandle { /// A newer version was found in the local cache from a previous run. CachedAvailable { - current: String, - latest: updater::LatestRelease, + checker: updater::Checker, + latest: kaishin::LatestRelease, }, /// A background check is currently in progress. Pending { - current: String, - rx: std::sync::mpsc::Receiver>, + checker: updater::Checker, + rx: std::sync::mpsc::Receiver>, + /// A newer version already known from the local cache. + cached_latest: Option, }, } @@ -287,78 +289,56 @@ fn maybe_spawn_auto_update_check() -> Option { return None; } - let state = updater::load_check_state(); - let now = std::time::SystemTime::now(); - let current = env!("CARGO_PKG_VERSION").to_string(); + let checker = updater::Checker::new().ok()?; + let interval = loaded + .config + .ui + .update_check_interval + .as_deref() + .and_then(|s| kaishin::parse_interval(s).ok()) + .unwrap_or_else(updater::default_interval); - let interval = match loaded.config.ui.update_check_interval.as_deref() { - Some(s) => match humantime::parse_duration(s) { - Ok(d) => d, - Err(e) => { - tracing::warn!(value = %s, error = %e, "invalid ui.update_check_interval; using default"); - updater::default_interval() - } - }, - None => updater::default_interval(), - }; + let checker = checker.interval(interval); - if !updater::should_auto_check(state.as_ref(), interval, now) { - if let Some(state) = state { - if let Some(cached_tag) = state.last_known_latest.as_ref() { - if updater::is_update_available(¤t, cached_tag).unwrap_or(false) { - return Some(AutoUpdateHandle::CachedAvailable { - current, - latest: updater::LatestRelease { - tag_name: cached_tag.clone(), - html_url: state.last_known_url.unwrap_or_default(), - }, - }); - } - } + if !checker.should_check() { + if let Some(latest) = checker.cached_update() { + return Some(AutoUpdateHandle::CachedAvailable { checker, latest }); } return None; } + let cached_latest = checker.cached_update(); let (tx, rx) = std::sync::mpsc::channel(); + let checker_clone = updater::Checker::new().ok()?.interval(interval); std::thread::spawn(move || { - let _ = tx.send(updater::check_latest_release()); + let _ = tx.send(checker_clone.check_and_save()); }); - Some(AutoUpdateHandle::Pending { current, rx }) + Some(AutoUpdateHandle::Pending { + checker, + rx, + cached_latest, + }) } /// Waits for the background update check to complete (with a short timeout) and prints a banner if an update is available. fn finalize_auto_update_check(handle: AutoUpdateHandle) { match handle { - AutoUpdateHandle::CachedAvailable { current, latest } => { - eprintln!("\n{}", updater::format_update_banner(¤t, &latest)); + AutoUpdateHandle::CachedAvailable { checker, latest } => { + eprintln!("\n{}", checker.format_banner(&latest)); } - AutoUpdateHandle::Pending { current, rx } => { + AutoUpdateHandle::Pending { + checker, + rx, + cached_latest, + } => { // Wait for 1 second. let res = rx.recv_timeout(std::time::Duration::from_secs(1)); - let now_unix = std::time::SystemTime::now() - .duration_since(std::time::SystemTime::UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0); - - let mut state = updater::load_check_state().unwrap_or(updater::UpdateCheckState { - last_checked_unix: 0, - last_known_latest: None, - last_known_url: None, - }); - - state.last_checked_unix = now_unix; - if let Ok(Ok(latest)) = res { - state.last_known_latest = Some(latest.tag_name.clone()); - state.last_known_url = Some(latest.html_url.clone()); - let _ = updater::save_check_state(&state); - if updater::is_update_available(¤t, &latest.tag_name).unwrap_or(false) { - eprintln!("\n{}", updater::format_update_banner(¤t, &latest)); - } - } else { - // Even on timeout or error, update the last_checked_unix to avoid constant checking. - let _ = updater::save_check_state(&state); + eprintln!("\n{}", checker.format_banner(&latest)); + } else if let Some(latest) = cached_latest { + // Fallback to cached version on timeout or fetch error. + eprintln!("\n{}", checker.format_banner(&latest)); } } } diff --git a/src/updater.rs b/src/updater.rs index b065298..001451b 100644 --- a/src/updater.rs +++ b/src/updater.rs @@ -1,259 +1,72 @@ -//! Self-update support for renri, mirroring rvpm's strategy. +//! Self-update support for renri, using `kaishin` library. -use std::path::{Path, PathBuf}; -use std::time::{Duration, SystemTime}; - -use anyhow::{Result, anyhow}; -use serde::{Deserialize, Serialize}; - -/// Minimum fields from GitHub releases API. -/// Information about the latest release fetched from GitHub. -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] -pub struct LatestRelease { - /// The tag name of the release (e.g., "v0.1.0"). - pub tag_name: String, - /// The URL to the release's HTML page on GitHub. - #[serde(default)] - pub html_url: String, -} - -/// The method by which renri was installed. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum InstallMethod { - /// Installed via `cargo install`. - CargoInstall, - /// A development build (running from `target/`). - DevBuild, - /// Downloaded and run as a standalone binary. - DirectBinary, -} - -/// Persistent state for the background update check. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct UpdateCheckState { - /// Unix timestamp of the last successful check. - pub last_checked_unix: u64, - /// The last known latest version tag. - pub last_known_latest: Option, - /// The last known latest version URL. - pub last_known_url: Option, -} +use anyhow::Result; +use std::time::Duration; /// Returns the default interval between update checks (24 hours). pub fn default_interval() -> Duration { - Duration::from_secs(86400) // 24h -} - -/// Detects the installation method of the current executable. -pub fn detect_install_method(exe: &Path) -> InstallMethod { - let s = exe.to_string_lossy().replace('\\', "/").to_lowercase(); - if s.contains("/target/debug/") || s.contains("/target/release/") { - return InstallMethod::DevBuild; - } - let cargo_bin = std::env::var("CARGO_HOME") - .ok() - .map(PathBuf::from) - .or_else(|| dirs::home_dir().map(|h| h.join(".cargo"))) - .map(|p| { - p.join("bin") - .to_string_lossy() - .replace('\\', "/") - .to_lowercase() - }); - if let Some(bin) = cargo_bin { - if s.starts_with(&format!("{}/", bin)) { - return InstallMethod::CargoInstall; - } - } - if s.contains("/.cargo/bin/") || s.contains("/cargo/bin/") { - return InstallMethod::CargoInstall; - } - InstallMethod::DirectBinary + kaishin::default_interval() } -/// Checks if a newer version is available compared to the current version. -pub fn is_update_available(current: &str, latest_tag: &str) -> Result { - let cur = semver::Version::parse(current) - .map_err(|e| anyhow!("invalid current version `{}`: {}", current, e))?; - let lat_str = latest_tag.trim_start_matches('v'); - let lat = semver::Version::parse(lat_str) - .map_err(|e| anyhow!("invalid latest tag `{}`: {}", latest_tag, e))?; - Ok(lat > cur) -} +/// Runs the self-update process, either check-only or interactive/non-interactive install. +pub fn run_self_update(yes: bool, check_only: bool, non_interactive: bool) -> Result<()> { + let opts = kaishin::KaishinOptions::new( + "yukimemi", + env!("CARGO_PKG_NAME"), + env!("CARGO_PKG_NAME"), + env!("CARGO_PKG_VERSION"), + ); + let upd_opts = kaishin::UpdateOptions::new() + .yes(yes) + .check_only(check_only) + .non_interactive(non_interactive); -/// Fetches the latest release information from the GitHub API. -pub fn check_latest_release() -> Result { - let url = "https://api.github.com/repos/yukimemi/renri/releases/latest"; - let client = reqwest::blocking::Client::builder() - .user_agent(format!("renri/{}", env!("CARGO_PKG_VERSION"))) - .timeout(Duration::from_secs(5)) + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() .build()?; - let res = client.get(url).send()?; - if !res.status().is_success() { - return Err(anyhow!("GitHub releases API returned {}", res.status())); - } - let release: LatestRelease = res.json()?; - Ok(release) -} - -/// Returns the path to the update check state file in the user's data directory. -fn state_path() -> Option { - dirs::data_dir().map(|d| d.join("renri").join("last_update_check.json")) + rt.block_on(async { kaishin::run_self_update(&opts, upd_opts).await }) } -/// Loads the update check state from the persistent state file. -pub fn load_check_state() -> Option { - let p = state_path()?; - let content = std::fs::read_to_string(p).ok()?; - serde_json::from_str(&content).ok() +/// A high-level handler for managing background update checks in a blocking context. +pub struct Checker { + inner: kaishin::Checker, } -/// Saves the update check state to the persistent state file atomically. -pub fn save_check_state(state: &UpdateCheckState) -> Result<()> { - if let Some(p) = state_path() { - if let Some(parent) = p.parent() { - std::fs::create_dir_all(parent)?; - let json = serde_json::to_string(state)?; - let mut tmp = tempfile::NamedTempFile::new_in(parent)?; - use std::io::Write; - tmp.write_all(json.as_bytes())?; - tmp.persist(&p)?; - } +impl Checker { + pub fn new() -> Result { + let opts = kaishin::KaishinOptions::new( + "yukimemi", + env!("CARGO_PKG_NAME"), + env!("CARGO_PKG_NAME"), + env!("CARGO_PKG_VERSION"), + ); + let inner = kaishin::Checker::new(env!("CARGO_PKG_NAME"), opts); + Ok(Self { inner }) } - Ok(()) -} - -/// Determines if an automatic update check should be performed based on the interval. -pub fn should_auto_check( - state: Option<&UpdateCheckState>, - interval: Duration, - now: SystemTime, -) -> bool { - let Some(state) = state else { - return true; - }; - let Ok(now_unix) = now.duration_since(SystemTime::UNIX_EPOCH) else { - return true; - }; - let elapsed = now_unix.as_secs().saturating_sub(state.last_checked_unix); - elapsed >= interval.as_secs() -} -/// Formats a banner message to notify the user of an available update. -pub fn format_update_banner(current: &str, latest: &LatestRelease) -> String { - let tag = latest.tag_name.trim_start_matches('v'); - let mut s = format!( - "\u{2699} renri {} available (current {}) — run `renri self-update` to upgrade", - tag, current - ); - if !latest.html_url.is_empty() { - s.push_str(&format!("\n release notes: {}", latest.html_url)); + pub fn interval(mut self, interval: Duration) -> Self { + self.inner = self.inner.interval(interval); + self } - s -} - -/// Runs the self-update process, either check-only or interactive/non-interactive install. -pub fn run_self_update(yes: bool, check_only: bool, non_interactive: bool) -> Result<()> { - let current = env!("CARGO_PKG_VERSION"); - let latest = check_latest_release()?; - let available = is_update_available(current, &latest.tag_name)?; - if !available { - println!("\u{2713} renri {} is already up to date.", current); - return Ok(()); + pub fn should_check(&self) -> bool { + self.inner.should_check() } - let latest_clean = latest.tag_name.trim_start_matches('v'); - if check_only { - println!( - "\u{2699} renri {} available (current {}). Run `renri self-update` to install.", - latest_clean, current - ); - if !latest.html_url.is_empty() { - println!(" release notes: {}", latest.html_url); - } - return Ok(()); + pub fn check_and_save(&self) -> Result { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + rt.block_on(async { self.inner.check_and_save().await }) } - if !yes { - use std::io::IsTerminal; - if non_interactive || !std::io::stdin().is_terminal() { - anyhow::bail!( - "non-interactive mode: use `--yes` to proceed with update to v{}", - latest_clean - ); - } - - eprint!("Update to v{}? [y/N] ", latest_clean); - use std::io::Write; - std::io::stderr().flush().ok(); - let mut answer = String::new(); - std::io::stdin().read_line(&mut answer)?; - let answer = answer.trim().to_ascii_lowercase(); - if answer != "y" && answer != "yes" { - eprintln!("aborted."); - return Ok(()); - } + pub fn cached_update(&self) -> Option { + self.inner.cached_update() } - let exe = std::env::current_exe()?; - let method = detect_install_method(&exe); - match method { - InstallMethod::DevBuild => { - anyhow::bail!( - "\u{26a0} `{}` looks like a development build. Refusing to self-update.", - exe.display() - ); - } - InstallMethod::CargoInstall => { - let tmp = tempfile::Builder::new() - .prefix("renri-self-update-") - .tempdir()?; - let tmp_root = tmp.path().to_path_buf(); - println!( - "running: cargo install renri --version {} --locked --force --root {}", - latest_clean, - tmp_root.display() - ); - let status = std::process::Command::new("cargo") - .arg("install") - .arg("renri") - .arg("--version") - .arg(latest_clean) - .arg("--locked") - .arg("--force") - .arg("--root") - .arg(&tmp_root) - .status()?; - if !status.success() { - anyhow::bail!("cargo install failed"); - } - let bin_name = if cfg!(windows) { "renri.exe" } else { "renri" }; - let new_exe = tmp_root.join("bin").join(bin_name); - self_update::self_replace::self_replace(&new_exe)?; - println!("\u{2713} renri v{} installed.", latest_clean); - } - InstallMethod::DirectBinary => { - let status = self_update::backends::github::Update::configure() - .repo_owner("yukimemi") - .repo_name("renri") - .bin_name("renri") - .show_download_progress(true) - .current_version(current) - .target_version_tag(&latest.tag_name) - .build() - .map_err(|e| anyhow!("build: {}", e))? - .update() - .map_err(|e| anyhow!("update: {}", e))?; - match status { - self_update::Status::UpToDate(v) => { - println!("\u{2713} renri {} is already up to date.", v) - } - self_update::Status::Updated(v) => println!("\u{2713} renri v{} installed.", v), - } - } + pub fn format_banner(&self, latest: &kaishin::LatestRelease) -> String { + self.inner.format_banner(latest) } - Ok(()) } #[cfg(test)] @@ -261,24 +74,8 @@ mod tests { use super::*; #[test] - fn test_is_update_available() { - assert!(is_update_available("0.1.0", "v0.1.1").unwrap()); - assert!(!is_update_available("0.1.1", "v0.1.1").unwrap()); - assert!(!is_update_available("0.1.2", "v0.1.1").unwrap()); - } - - #[test] - fn test_detect_install_method() { - let p = PathBuf::from("/home/u/.cargo/bin/renri"); - assert_eq!(detect_install_method(&p), InstallMethod::CargoInstall); - - let p = PathBuf::from( - r"C:\Users\yukimemi\src\github.com\yukimemi\renri\target\debug\renri.exe", - ); - assert_eq!(detect_install_method(&p), InstallMethod::DevBuild); - - // Use a path that is unlikely to overlap with CARGO_HOME or home_dir for DirectBinary test. - let p = PathBuf::from("/opt/renri-bin/renri"); - assert_eq!(detect_install_method(&p), InstallMethod::DirectBinary); + fn test_checker_init() { + // Test that checker can be initialized without panic. + let _ = Checker::new().unwrap(); } }