#[cfg(all(feature = "cli", feature = "self-update"))]
use std::io::BufRead;
#[cfg(feature = "self-update")]
use std::io::Write as _;
use std::path::{Path, PathBuf};
#[cfg(feature = "self-update")]
use std::time::Duration;
use serde::Deserialize;
#[cfg(feature = "self-update")]
use crate::Error;
#[cfg(feature = "self-update")]
use crate::Result;
#[cfg(all(feature = "cli", feature = "self-update"))]
use crate::release::latest_release;
use crate::release::{Release, ReleaseSource};
#[cfg(feature = "self-update")]
const MAX_DOWNLOAD: u64 = 512 * 1024 * 1024;
#[cfg(feature = "self-update")]
const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(15 * 60);
#[cfg(feature = "cli")]
#[derive(Clone, Debug, Default, clap::Args)]
pub struct UpdateArgs {
#[arg(long)]
pub check: bool,
#[arg(long, short = 'y')]
pub yes: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum InstallMethod {
Release,
Cargo,
Binstall {
crate_name: String,
source: BinstallSource,
},
PackageManager,
Store(StoreKind),
Velopack,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum StoreKind {
Flatpak,
Snap,
Msix,
MacAppStore,
}
#[derive(Clone, Debug)]
pub(crate) struct Probe {
pub root: PathBuf,
pub snap: bool,
pub msix: bool,
pub appimage: Option<PathBuf>,
}
impl Probe {
pub(crate) fn detect() -> Self {
Self {
root: PathBuf::from("/"),
snap: std::env::var_os("SNAP").is_some_and(|v| !v.is_empty()),
msix: has_package_identity(),
appimage: std::env::var_os("APPIMAGE")
.filter(|v| !v.is_empty())
.map(PathBuf::from),
}
}
}
#[cfg(windows)]
fn has_package_identity() -> bool {
use windows::Win32::Foundation::ERROR_INSUFFICIENT_BUFFER;
use windows::Win32::Storage::Packaging::Appx::GetCurrentPackageFullName;
let mut len = 0u32;
let status = unsafe { GetCurrentPackageFullName(&mut len, None) };
status == ERROR_INSUFFICIENT_BUFFER
}
#[cfg(not(windows))]
fn has_package_identity() -> bool {
false
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum BinstallSource {
Registry,
Git(String),
Other,
}
pub fn install_method(exe: &Path, cargo_home: Option<&Path>) -> InstallMethod {
install_method_with(exe, cargo_home, &Probe::detect())
}
pub(crate) fn install_method_with(
exe: &Path,
cargo_home: Option<&Path>,
probe: &Probe,
) -> InstallMethod {
if probe.root.join(".flatpak-info").exists() {
return InstallMethod::Store(StoreKind::Flatpak);
}
if probe.snap {
return InstallMethod::Store(StoreKind::Snap);
}
if probe.msix {
return InstallMethod::Store(StoreKind::Msix);
}
if let Some(macos) = exe.parent()
&& let Some(contents) = macos.parent()
&& macos.file_name().is_some_and(|d| d == "MacOS")
&& contents.file_name().is_some_and(|d| d == "Contents")
&& contents.join("_MASReceipt").join("receipt").exists()
{
return InstallMethod::Store(StoreKind::MacAppStore);
}
if corundum_framework_platform::package::current_with(exe, probe.appimage.as_deref()).is_some()
{
return InstallMethod::Velopack;
}
if let Some(home) = cargo_home
&& exe.starts_with(home.join("bin"))
{
let records = std::fs::read_to_string(home.join("binstall").join("crates-v1.json"));
let bin = exe.file_name().and_then(|name| name.to_str()).map(|name| {
name.strip_suffix(std::env::consts::EXE_SUFFIX)
.unwrap_or(name)
});
return match (records, bin) {
(Ok(records), Some(bin)) => binstalled(&records, bin),
_ => None,
}
.unwrap_or(InstallMethod::Cargo);
}
if exe.starts_with("/usr/local") {
return InstallMethod::Release;
}
const MANAGED: [&str; 7] = [
"/usr",
"/bin",
"/sbin",
"/nix",
"/opt/homebrew",
"/home/linuxbrew",
"/snap",
];
if MANAGED.iter().any(|dir| exe.starts_with(dir)) {
return InstallMethod::PackageManager;
}
InstallMethod::Release
}
#[derive(Deserialize)]
struct BinstallRecord {
name: String,
#[serde(default)]
source: Option<BinstallRecordSource>,
#[serde(default)]
bins: Vec<String>,
}
#[derive(Deserialize)]
struct BinstallRecordSource {
source_type: String,
url: String,
}
fn binstalled(records: &str, bin: &str) -> Option<InstallMethod> {
serde_json::Deserializer::from_str(records)
.into_iter::<BinstallRecord>()
.map_while(std::result::Result::ok)
.filter(|record| record.bins.iter().any(|b| b == bin))
.last()
.map(|record| InstallMethod::Binstall {
source: match record.source {
Some(source) if source.source_type == "Registry" => BinstallSource::Registry,
Some(source) if source.source_type == "Git" => {
let url = source
.url
.split_once('#')
.map_or(source.url.as_str(), |(u, _)| u);
BinstallSource::Git(url.to_owned())
}
_ => BinstallSource::Other,
},
crate_name: record.name,
})
}
pub fn platform() -> String {
format!("{}-{}", std::env::consts::OS, std::env::consts::ARCH)
}
pub fn asset_name(base: &str) -> String {
format!("{base}-{}{}", platform(), std::env::consts::EXE_SUFFIX)
}
#[cfg(feature = "self-update")]
pub(crate) fn stage(dir: &Path, bytes: &[u8]) -> Result<tempfile::NamedTempFile> {
let mut staged = tempfile::Builder::new()
.prefix(".update-")
.tempfile_in(dir)?;
staged.write_all(bytes)?;
staged.as_file().sync_all()?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(staged.path(), std::fs::Permissions::from_mode(0o755))?;
}
Ok(staged)
}
#[cfg(feature = "self-update")]
pub fn install(
bytes: &[u8],
exe: &Path,
replace: impl FnOnce(&Path) -> std::io::Result<()>,
) -> Result<()> {
let dir = exe.parent().ok_or_else(|| {
Error::Io(std::io::Error::other(format!(
"{} has no directory",
exe.display()
)))
})?;
let staged = stage(dir, bytes)?;
replace(staged.path())?;
Ok(())
}
#[cfg(feature = "self-update")]
async fn download(client: &reqwest::Client, url: &str) -> Result<Vec<u8>> {
let response = client
.get(url)
.timeout(DOWNLOAD_TIMEOUT)
.send()
.await
.and_then(reqwest::Response::error_for_status)
.map_err(|e| Error::Http(format!("downloading {url}: {e}")))?;
if response.content_length().is_some_and(|n| n > MAX_DOWNLOAD) {
return Err(Error::Http(format!(
"{url} is larger than {MAX_DOWNLOAD} bytes"
)));
}
let bytes = response
.bytes()
.await
.map_err(|e| Error::Http(format!("downloading {url}: {e}")))?;
if bytes.len() as u64 > MAX_DOWNLOAD {
return Err(Error::Http(format!(
"{url} is larger than {MAX_DOWNLOAD} bytes"
)));
}
Ok(bytes.to_vec())
}
#[cfg(feature = "self-update")]
pub async fn download_asset(
client: &reqwest::Client,
source: &ReleaseSource,
release: &Release,
) -> Result<Vec<u8>> {
let base = source.asset_base().ok_or_else(|| {
Error::NotConfigured(format!(
"{} does not name a release file to download",
source.repository()
))
})?;
let name = asset_name(base);
let binary = release
.assets
.iter()
.find(|a| a.name == name)
.ok_or_else(|| {
Error::NoAsset(format!(
"{} {} has no {name}",
source.repository(),
release.version
))
})?;
let bytes = download(client, &binary.download_url).await?;
if bytes.is_empty() {
return Err(Error::Http(format!("{name} downloaded empty")));
}
Ok(bytes)
}
pub fn advice_command(source: &ReleaseSource, method: &InstallMethod) -> Option<String> {
match (method, source.crate_name()) {
(InstallMethod::Cargo, Some(name)) => Some(format!("cargo install {name} --locked")),
(InstallMethod::Binstall { crate_name, source }, _) => match source {
BinstallSource::Registry => Some(format!("cargo binstall {crate_name}")),
BinstallSource::Git(url) => Some(format!("cargo binstall --git {url} {crate_name}")),
BinstallSource::Other => None,
},
_ => None,
}
}
pub fn advice(source: &ReleaseSource, release: &Release, method: &InstallMethod) -> String {
if let Some(command) = advice_command(source, method) {
let tool = match method {
InstallMethod::Cargo => "cargo",
_ => "cargo binstall",
};
return format!("installed with {tool}; run: {command}");
}
match method {
InstallMethod::Cargo => "installed with cargo; update it with cargo install".to_owned(),
InstallMethod::Binstall { .. } => {
"installed with cargo binstall; update it with cargo binstall".to_owned()
}
InstallMethod::PackageManager => {
"installed by a package manager; update it with that".to_owned()
}
InstallMethod::Store(store) => format!(
"installed from {}; it updates there",
match store {
StoreKind::Flatpak => "Flathub",
StoreKind::Snap => "the Snap Store",
StoreKind::Msix => "the Microsoft Store",
StoreKind::MacAppStore => "the Mac App Store",
}
),
InstallMethod::Velopack => "Updated by the app itself.".to_owned(),
InstallMethod::Release => format!("download it from {}", release.url),
}
}
pub fn cargo_home() -> Option<PathBuf> {
std::env::var_os("CARGO_HOME")
.map(PathBuf::from)
.or_else(|| std::env::home_dir().map(|home| home.join(".cargo")))
}
#[cfg(all(feature = "cli", feature = "self-update"))]
pub async fn run(
args: &UpdateArgs,
source: &ReleaseSource,
name: &str,
current: &str,
) -> Result<i32> {
let current_v = semver::Version::parse(current).map_err(|e| {
Error::NotConfigured(format!(
"this binary's version {current:?} is not semver: {e}"
))
})?;
let client = crate::client(&format!("{name}/{current}"))?;
let release = latest_release(&client, source).await?;
if release.version <= current_v {
println!(
"{name} {current} is up to date (latest release {})",
release.version
);
return Ok(0);
}
println!(
"{name} {} is available (installed: {current}): {}",
release.version, release.url
);
if args.check {
return Ok(0);
}
if source.asset_base().is_none() {
println!(
"{name} does not update itself; {}",
advice(source, &release, &InstallMethod::Release)
);
return Ok(1);
}
let exe = std::env::current_exe()?;
let exe = exe.canonicalize().unwrap_or(exe);
let method = install_method(&exe, cargo_home().as_deref());
if method != InstallMethod::Release {
println!("{} is {}", exe.display(), advice(source, &release, &method));
return Ok(1);
}
if !args.yes
&& !confirm(&format!(
"replace {} with {}?",
exe.display(),
release.version
))?
{
println!("not updated");
return Ok(1);
}
let bytes = download_asset(&client, source, &release).await?;
install(&bytes, &exe, |staged| self_replace::self_replace(staged))?;
println!(
"installed {name} {}; restart the {name} service to run it",
release.version
);
Ok(0)
}
#[cfg(all(feature = "cli", feature = "self-update"))]
fn confirm(question: &str) -> Result<bool> {
print!("{question} [y/N] ");
std::io::stdout().flush()?;
let mut answer = String::new();
std::io::stdin().lock().read_line(&mut answer)?;
Ok(matches!(
answer.trim().to_ascii_lowercase().as_str(),
"y" | "yes"
))
}
#[cfg(test)]
mod tests {
use super::*;
fn probe(root: &Path) -> Probe {
Probe {
root: root.to_owned(),
snap: false,
msix: false,
appimage: None,
}
}
#[test]
fn a_velopack_package_is_the_package_s_to_update() {
let tmp = tempfile::tempdir().unwrap();
let current = tmp.path().join("current");
std::fs::create_dir(¤t).unwrap();
std::fs::write(tmp.path().join("Update.exe"), "").unwrap();
let exe = current.join("app.exe");
let empty = tempfile::tempdir().unwrap();
let method = install_method_with(&exe, None, &probe(empty.path()));
assert_eq!(method, InstallMethod::Velopack);
let source = ReleaseSource::github("o", "r");
assert_eq!(advice_command(&source, &method), None);
}
#[test]
fn advice_carries_the_command_to_run() {
let source = ReleaseSource::github("o", "r").cargo_crate("app-crate");
let release = Release {
version: "1.0.0".parse().unwrap(),
url: "https://example.test/r".to_owned(),
assets: vec![],
};
let binstall = |source| InstallMethod::Binstall {
crate_name: "app-crate".to_owned(),
source,
};
let git = binstall(BinstallSource::Git("https://github.com/o/r".to_owned()));
let cases = [
(
InstallMethod::Cargo,
Some("cargo install app-crate --locked"),
"installed with cargo; run: cargo install app-crate --locked",
),
(
binstall(BinstallSource::Registry),
Some("cargo binstall app-crate"),
"installed with cargo binstall; run: cargo binstall app-crate",
),
(
git,
Some("cargo binstall --git https://github.com/o/r app-crate"),
"installed with cargo binstall; run: cargo binstall --git https://github.com/o/r app-crate",
),
(
binstall(BinstallSource::Other),
None,
"installed with cargo binstall; update it with cargo binstall",
),
(
InstallMethod::PackageManager,
None,
"installed by a package manager; update it with that",
),
(
InstallMethod::Release,
None,
"download it from https://example.test/r",
),
];
for (method, command, words) in cases {
assert_eq!(advice_command(&source, &method).as_deref(), command);
assert_eq!(advice(&source, &release, &method), words);
}
let bare = ReleaseSource::github("o", "r");
assert_eq!(advice_command(&bare, &InstallMethod::Cargo), None);
assert_eq!(
advice(&bare, &release, &InstallMethod::Cargo),
"installed with cargo; update it with cargo install"
);
}
#[test]
fn a_binary_is_replaced_only_where_it_was_placed_by_hand() {
let home = Path::new("/home/u/.cargo");
let root = tempfile::tempdir().unwrap();
let method = |p: &str| install_method_with(Path::new(p), Some(home), &probe(root.path()));
assert_eq!(method("/home/u/.cargo/bin/app"), InstallMethod::Cargo);
assert_eq!(method("/usr/bin/app"), InstallMethod::PackageManager);
assert_eq!(
method("/nix/store/abc-app/bin/app"),
InstallMethod::PackageManager
);
assert_eq!(
method("/opt/homebrew/bin/app"),
InstallMethod::PackageManager
);
assert_eq!(method("/usr/local/bin/app"), InstallMethod::Release);
assert_eq!(method("/home/u/.local/bin/app"), InstallMethod::Release);
}
#[test]
fn an_app_in_a_store_package_is_the_store_s_to_update() {
let tmp = tempfile::tempdir().unwrap();
let exe = Path::new("/home/u/.local/bin/app");
let method = |p: &Probe| install_method_with(exe, None, p);
assert_eq!(method(&probe(tmp.path())), InstallMethod::Release);
std::fs::write(tmp.path().join(".flatpak-info"), "").unwrap();
assert_eq!(
method(&probe(tmp.path())),
InstallMethod::Store(StoreKind::Flatpak)
);
let empty = tempfile::tempdir().unwrap();
let snap = Probe {
snap: true,
..probe(empty.path())
};
assert_eq!(method(&snap), InstallMethod::Store(StoreKind::Snap));
let msix = Probe {
msix: true,
..probe(empty.path())
};
assert_eq!(method(&msix), InstallMethod::Store(StoreKind::Msix));
}
#[test]
fn a_mac_app_store_bundle_is_recognised_by_its_receipt() {
let tmp = tempfile::tempdir().unwrap();
let contents = tmp.path().join("App.app").join("Contents");
std::fs::create_dir_all(contents.join("MacOS")).unwrap();
let exe = contents.join("MacOS").join("app");
let p = probe(tmp.path());
assert_eq!(install_method_with(&exe, None, &p), InstallMethod::Release);
std::fs::create_dir(contents.join("_MASReceipt")).unwrap();
std::fs::write(contents.join("_MASReceipt").join("receipt"), "").unwrap();
assert_eq!(
install_method_with(&exe, None, &p),
InstallMethod::Store(StoreKind::MacAppStore)
);
}
#[test]
fn binstall_s_records_name_the_crate_and_where_it_came_from() {
let records = concat!(
r#"{"name":"dioxus-cli","version_req":"*","current_version":"0.7.3","#,
r#""source":{"source_type":"Registry","url":"https://github.com/rust-lang/crates.io-index"},"#,
r#""target":"x86_64-unknown-linux-gnu","bins":["dx"]}"#,
r#"{"name":"sapphire-journal-desktop","version_req":"*","current_version":"0.3.0","#,
r#""source":{"source_type":"Git","url":"https://github.com/sapphire-turtle/sapphire-journal#b05d5c2"},"#,
r#""target":"x86_64-unknown-linux-gnu","bins":["sapphire-journal-desktop"]}"#,
);
assert_eq!(
binstalled(records, "dx"),
Some(InstallMethod::Binstall {
crate_name: "dioxus-cli".to_owned(),
source: BinstallSource::Registry,
})
);
assert_eq!(
binstalled(records, "sapphire-journal-desktop"),
Some(InstallMethod::Binstall {
crate_name: "sapphire-journal-desktop".to_owned(),
source: BinstallSource::Git(
"https://github.com/sapphire-turtle/sapphire-journal".to_owned()
),
})
);
assert_eq!(binstalled(records, "sapphire-journal"), None);
assert_eq!(binstalled("", "dx"), None);
}
#[test]
fn a_binary_under_cargo_s_bin_is_binstall_s_only_when_it_says_so() {
let tmp = tempfile::tempdir().unwrap();
let exe = tmp
.path()
.join("bin")
.join(format!("dx{}", std::env::consts::EXE_SUFFIX));
assert_eq!(
install_method_with(&exe, Some(tmp.path()), &probe(tmp.path())),
InstallMethod::Cargo
);
std::fs::create_dir(tmp.path().join("binstall")).unwrap();
std::fs::write(
tmp.path().join("binstall").join("crates-v1.json"),
r#"{"name":"dioxus-cli","source":{"source_type":"Registry","url":""},"bins":["dx"]}"#,
)
.unwrap();
assert_eq!(
install_method_with(&exe, Some(tmp.path()), &probe(tmp.path())),
InstallMethod::Binstall {
crate_name: "dioxus-cli".to_owned(),
source: BinstallSource::Registry,
}
);
}
#[test]
fn the_asset_names_this_machine() {
let name = asset_name("sapphire-bridge");
assert!(name.starts_with("sapphire-bridge-"));
assert!(name.contains(std::env::consts::OS));
assert!(name.contains(std::env::consts::ARCH));
}
#[test]
#[cfg(feature = "self-update")]
fn install_puts_the_bytes_in_place_and_leaves_no_staging_file() {
let tmp = tempfile::tempdir().unwrap();
let exe = tmp.path().join("app");
std::fs::write(&exe, b"old").unwrap();
install(b"new", &exe, |staged| {
std::fs::copy(staged, &exe).map(|_| ())
})
.unwrap();
assert_eq!(std::fs::read(&exe).unwrap(), b"new");
let left: Vec<_> = std::fs::read_dir(tmp.path()).unwrap().collect();
assert_eq!(left.len(), 1, "the staging file is gone");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let staged_mode = std::fs::metadata(&exe).unwrap().permissions().mode();
assert_eq!(
staged_mode & 0o111,
0o111,
"the copy keeps the executable bit"
);
}
}
#[cfg(feature = "self-update")]
mod download {
use super::*;
use crate::latest_release;
fn v(s: &str) -> semver::Version {
semver::Version::parse(s).unwrap()
}
async fn serve(routes: impl FnOnce(&str) -> Vec<(String, Vec<u8>)>) -> String {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let root = format!("http://{}", listener.local_addr().unwrap());
let routes = routes(&root);
tokio::spawn(async move {
loop {
let (mut socket, _) = listener.accept().await.unwrap();
let mut buf = vec![0u8; 8192];
let n = socket.read(&mut buf).await.unwrap();
let request = String::from_utf8_lossy(&buf[..n]);
let path = request.split_whitespace().nth(1).unwrap_or("").to_owned();
let (status, body) = match routes.iter().find(|(p, _)| *p == path) {
Some((_, body)) => ("200 OK", body.clone()),
None => ("404 Not Found", Vec::new()),
};
let head = format!(
"HTTP/1.1 {status}\r\ncontent-length: {}\r\nconnection: close\r\n\r\n",
body.len()
);
socket.write_all(head.as_bytes()).await.unwrap();
socket.write_all(&body).await.unwrap();
}
});
root
}
const RELEASES: &str = "/repos/o/r/releases?per_page=100";
async fn repository(binary: &[u8]) -> String {
let name = asset_name("app");
let binary = binary.to_vec();
serve(move |root| {
let releases = format!(
r#"[{{"tag_name":"v0.20.0","html_url":"{root}/r/v0.20.0","assets":[
{{"name":"{name}","browser_download_url":"{root}/dl/{name}"}}]}}]"#
);
vec![
(RELEASES.to_owned(), releases.into_bytes()),
(format!("/dl/{name}"), binary),
]
})
.await
}
#[tokio::test]
async fn a_release_is_fetched_end_to_end() {
let root = repository(b"new binary").await;
let source = ReleaseSource::github("o", "r").api_base(&root).asset("app");
let client = crate::client("test/0.0.0").unwrap();
let release = latest_release(&client, &source).await.unwrap();
assert_eq!(release.version, v("0.20.0"));
let bytes = download_asset(&client, &source, &release).await.unwrap();
assert_eq!(bytes, b"new binary");
}
#[tokio::test]
async fn an_empty_download_is_refused() {
let root = repository(b"").await;
let source = ReleaseSource::github("o", "r").api_base(&root).asset("app");
let client = crate::client("test/0.0.0").unwrap();
let release = latest_release(&client, &source).await.unwrap();
let err = download_asset(&client, &source, &release)
.await
.unwrap_err();
assert!(matches!(err, Error::Http(_)), "{err}");
}
#[tokio::test]
async fn a_release_without_this_platforms_file_is_refused() {
let root = serve(|_| {
vec![(
RELEASES.to_owned(),
br#"[{"tag_name":"v0.20.0","html_url":"https://example.invalid","assets":[]}]"#
.to_vec(),
)]
})
.await;
let source = ReleaseSource::github("o", "r").api_base(&root).asset("app");
let client = crate::client("test/0.0.0").unwrap();
let release = latest_release(&client, &source).await.unwrap();
let err = download_asset(&client, &source, &release)
.await
.unwrap_err();
assert!(matches!(err, Error::NoAsset(_)), "{err}");
}
}
}