use std::time::Duration;
use self_update::backends::github::{ReleaseList, Update};
use self_update::cargo_crate_version;
use crate::Result;
const REPO_OWNER: &str = "iyulab";
const REPO_NAME: &str = "shell-tunnel";
#[cfg(windows)]
const BIN_NAME: &str = "shell-tunnel.exe";
#[cfg(not(windows))]
const BIN_NAME: &str = "shell-tunnel";
fn release_target() -> Option<&'static str> {
Some(match (std::env::consts::OS, std::env::consts::ARCH) {
("windows", "x86_64") => "windows-x64",
("linux", "x86_64") => "linux-x64",
("linux", "aarch64") => "linux-arm64",
("macos", "x86_64") => "macos-x64",
("macos", "aarch64") => "macos-arm64",
_ => return None,
})
}
#[derive(Debug)]
pub struct UpdateInfo {
pub current: String,
pub latest: String,
pub update_available: bool,
}
pub fn check_update() -> Result<UpdateInfo> {
let current = cargo_crate_version!().to_string();
let releases = ReleaseList::configure()
.repo_owner(REPO_OWNER)
.repo_name(REPO_NAME)
.build()
.map_err(|e| crate::ShellTunnelError::Update(e.to_string()))?
.fetch()
.map_err(|e| crate::ShellTunnelError::Update(e.to_string()))?;
let latest = releases
.first()
.map(|r| r.version.clone())
.unwrap_or_else(|| current.clone());
let update_available = is_newer_version(¤t, &latest);
Ok(UpdateInfo {
current,
latest,
update_available,
})
}
fn select_update_tag(current: &str, latest: &str) -> Option<String> {
if is_newer_version(current, latest) {
Some(format!("v{}", latest.trim_start_matches('v')))
} else {
None
}
}
pub fn self_update() -> Result<bool> {
let target = release_target().ok_or_else(|| {
crate::ShellTunnelError::Update(format!(
"no release binary is published for {}-{}; build from source instead",
std::env::consts::OS,
std::env::consts::ARCH
))
})?;
let info = check_update()?;
let Some(tag) = select_update_tag(&info.current, &info.latest) else {
return Ok(false);
};
let status = Update::configure()
.repo_owner(REPO_OWNER)
.repo_name(REPO_NAME)
.bin_name(BIN_NAME)
.target(target)
.current_version(cargo_crate_version!())
.target_version_tag(&tag)
.show_download_progress(true)
.no_confirm(true)
.build()
.map_err(|e| crate::ShellTunnelError::Update(e.to_string()))?
.update()
.map_err(|e| crate::ShellTunnelError::Update(e.to_string()))?;
Ok(status.updated())
}
pub fn background_update_check() {
std::thread::spawn(|| {
std::thread::sleep(Duration::from_secs(2));
match check_update() {
Ok(info) => {
if info.update_available {
tracing::info!(
"Update available: {} -> {} (run with --update to install)",
info.current,
info.latest
);
} else {
tracing::debug!("Already running latest version: {}", info.current);
}
}
Err(e) => {
tracing::debug!("Update check failed: {}", e);
}
}
});
}
fn is_newer_version(current: &str, latest: &str) -> bool {
let parse = |v: &str| -> (u32, u32, u32) {
let v = v.trim_start_matches('v');
let parts: Vec<&str> = v.split('.').collect();
let major = parts.first().and_then(|s| s.parse().ok()).unwrap_or(0);
let minor = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
let patch = parts.get(2).and_then(|s| s.parse().ok()).unwrap_or(0);
(major, minor, patch)
};
let (cm, cn, cp) = parse(current);
let (lm, ln, lp) = parse(latest);
(lm, ln, lp) > (cm, cn, cp)
}
#[cfg(test)]
mod tests {
#[test]
fn this_platform_maps_to_a_published_asset() {
let target = release_target();
if matches!(
(std::env::consts::OS, std::env::consts::ARCH),
("windows", "x86_64")
| ("linux", "x86_64")
| ("linux", "aarch64")
| ("macos", "x86_64")
| ("macos", "aarch64")
) {
assert!(target.is_some(), "no asset naming for this platform");
}
}
#[test]
fn release_targets_match_the_published_assets() {
let workflow = std::fs::read_to_string(concat!(
env!("CARGO_MANIFEST_DIR"),
"/.github/workflows/release.yml"
))
.expect("release workflow should be readable");
for platform in [
"windows-x64",
"linux-x64",
"linux-arm64",
"macos-x64",
"macos-arm64",
] {
assert!(
workflow.contains(&format!("shell-tunnel-{platform}.")),
"the release workflow publishes no asset named shell-tunnel-{platform}.*"
);
}
}
use super::*;
#[test]
fn update_selects_latest_release_across_minor_bumps() {
assert_eq!(
select_update_tag("0.8.0", "0.9.1"),
Some("v0.9.1".to_string())
);
}
#[test]
fn update_is_a_noop_when_already_latest() {
assert_eq!(select_update_tag("0.9.1", "0.9.1"), None);
assert_eq!(select_update_tag("0.9.1", "0.8.1"), None);
}
#[test]
fn update_tag_tolerates_a_v_prefix_from_the_api() {
assert_eq!(
select_update_tag("0.8.0", "v0.9.1"),
Some("v0.9.1".to_string())
);
}
#[test]
fn release_workflow_tags_are_v_prefixed() {
let workflow = std::fs::read_to_string(concat!(
env!("CARGO_MANIFEST_DIR"),
"/.github/workflows/release.yml"
))
.expect("release workflow should be readable");
assert!(
workflow.contains("- 'v*'"),
"release workflow no longer triggers on v-prefixed tags; \
select_update_tag's tag reconstruction is broken"
);
}
#[test]
fn test_version_comparison() {
assert!(is_newer_version("0.1.0", "0.2.0"));
assert!(is_newer_version("0.1.0", "1.0.0"));
assert!(is_newer_version("0.1.0", "0.1.1"));
assert!(!is_newer_version("0.2.0", "0.1.0"));
assert!(!is_newer_version("0.1.0", "0.1.0"));
assert!(is_newer_version("v0.1.0", "v0.2.0"));
}
}