use std::{
fs,
io::Read,
path::{Path, PathBuf},
process::{Command, Stdio},
thread::{self, JoinHandle},
time::{Duration, Instant},
};
use anyhow::{Context, Result, ensure};
use semver::Version;
use serde::Deserialize;
mod process;
#[cfg(windows)]
mod windows;
const RECHECK_INTERVAL: Duration = Duration::from_secs(4 * 60 * 60);
const CHECK_TIMEOUT: Duration = Duration::from_secs(15);
const MAX_RESPONSE_BYTES: u64 = 2 * 1024 * 1024;
const REGISTRY: &str = "registry+https://github.com/rust-lang/crates.io-index";
const BINARY: &str = if cfg!(windows) {
"magi-code.exe"
} else {
"magi-code"
};
#[derive(Default)]
pub(crate) struct UpdateChecks {
next_check: Option<Instant>,
worker: Option<JoinHandle<Result<Option<Version>>>>,
notified: Option<Version>,
}
impl UpdateChecks {
pub(crate) fn poll(&mut self, now: Instant) -> Option<Version> {
self.poll_with_check(now, check_newer_version)
}
fn poll_with_check(
&mut self,
now: Instant,
check: impl FnOnce() -> Result<Option<Version>> + Send + 'static,
) -> Option<Version> {
let mut notification = None;
if self
.worker
.as_ref()
.is_some_and(|worker| worker.is_finished())
&& let Some(worker) = self.worker.take()
&& let Ok(Ok(Some(version))) = worker.join()
&& self
.notified
.as_ref()
.is_none_or(|previous| version > *previous)
{
self.notified = Some(version.clone());
notification = Some(version);
}
if self.worker.is_none() && self.next_check.is_none_or(|deadline| now >= deadline) {
self.next_check = Some(now + RECHECK_INTERVAL);
self.worker = thread::Builder::new()
.name("update-check".into())
.spawn(check)
.ok();
}
notification
}
}
#[derive(Deserialize)]
struct RegistryVersions {
versions: Vec<RegistryVersion>,
}
#[derive(Deserialize)]
struct RegistryVersion {
num: String,
yanked: bool,
}
fn newest_stable(body: &[u8], current: &Version) -> Result<Option<Version>> {
let response: RegistryVersions =
serde_json::from_slice(body).context("crates.io returned invalid version data")?;
let mut newest = None;
for entry in response.versions {
let version =
Version::parse(&entry.num).context("crates.io returned an invalid version")?;
if !entry.yanked
&& version.pre.is_empty()
&& version > *current
&& newest.as_ref().is_none_or(|previous| version > *previous)
{
newest = Some(version);
}
}
Ok(newest)
}
fn check_newer_version() -> Result<Option<Version>> {
let client = reqwest::blocking::Client::builder()
.timeout(CHECK_TIMEOUT)
.connect_timeout(Duration::from_secs(5))
.redirect(reqwest::redirect::Policy::none())
.user_agent(concat!(
"magi-code/",
env!("CARGO_PKG_VERSION"),
" update-check"
))
.build()?;
let response = client
.get("https://crates.io/api/v1/crates/magi-code")
.send()
.context("cannot check crates.io for updates")?
.error_for_status()?;
let mut body = Vec::new();
response
.take(MAX_RESPONSE_BYTES + 1)
.read_to_end(&mut body)?;
ensure!(
body.len() as u64 <= MAX_RESPONSE_BYTES,
"crates.io version response exceeds limit"
);
newest_stable(&body, &Version::parse(env!("CARGO_PKG_VERSION"))?)
}
#[derive(Debug)]
struct CargoInstallation {
executable: PathBuf,
root: PathBuf,
target: String,
}
impl CargoInstallation {
fn discover(executable: &Path) -> Result<Self> {
let executable = executable
.canonicalize()
.context("cannot resolve the running executable")?;
ensure!(
executable.file_name() == Some(std::ffi::OsStr::new(BINARY)),
"update requires a Cargo-installed {BINARY}; renamed binaries must be updated manually"
);
let bin = executable
.parent()
.context("executable has no parent directory")?;
ensure!(
bin.file_name() == Some(std::ffi::OsStr::new("bin")),
"not a Cargo installation; install with cargo install magi-code --locked"
);
let root = bin
.parent()
.context("Cargo bin directory has no installation root")?
.to_path_buf();
let tracking = read_tracking_file(&root.join(".crates.toml"))?;
let tracking: toml::Value =
toml::from_str(&tracking).context("invalid Cargo install record")?;
let packages = tracking
.get("v1")
.and_then(toml::Value::as_table)
.context("missing Cargo install records; update this installation manually")?;
let expected = format!("magi-code {} ({REGISTRY})", env!("CARGO_PKG_VERSION"));
let owners: Vec<_> = packages
.iter()
.filter(|(_, bins)| {
bins.as_array()
.is_some_and(|bins| bins.iter().any(|bin| bin.as_str() == Some(BINARY)))
})
.collect();
ensure!(
owners.len() == 1 && owners[0].0 == &expected,
"ambiguous, stale, or non-crates.io Cargo installation; update it manually with its original source and --root"
);
ensure!(
owners[0].1.as_array().is_some_and(|bins| bins.len() == 1),
"installation contains extra binaries; update it manually"
);
let detailed: serde_json::Value =
serde_json::from_str(&read_tracking_file(&root.join(".crates2.json"))?)
.context("invalid detailed Cargo install record")?;
let installs = detailed
.get("installs")
.and_then(serde_json::Value::as_object)
.context("missing detailed Cargo install records")?;
let detailed_owners: Vec<_> = installs
.iter()
.filter(|(_, record)| {
record
.get("bins")
.and_then(serde_json::Value::as_array)
.is_some_and(|bins| bins.iter().any(|bin| bin.as_str() == Some(BINARY)))
})
.collect();
ensure!(
detailed_owners.len() == 1 && detailed_owners[0].0 == &expected,
"Cargo install records disagree; repair or update this installation manually"
);
let record = detailed_owners[0].1;
ensure!(
record
.get("bins")
.and_then(serde_json::Value::as_array)
.is_some_and(|bins| bins.len() == 1),
"detailed Cargo record contains extra binaries; update manually"
);
ensure!(
record
.get("features")
.and_then(serde_json::Value::as_array)
.is_none_or(Vec::is_empty)
&& record
.get("all_features")
.and_then(serde_json::Value::as_bool)
!= Some(true)
&& record
.get("no_default_features")
.and_then(serde_json::Value::as_bool)
!= Some(true),
"custom Cargo feature installation; update manually with its original feature flags"
);
let target = record
.get("target")
.and_then(serde_json::Value::as_str)
.context("Cargo target record is missing; update manually")?;
ensure!(
!target.is_empty()
&& target.len() <= 128
&& target
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_'),
"custom Cargo target specification; update manually"
);
let target = target.to_owned();
Ok(Self {
executable,
root,
target,
})
}
}
fn read_tracking_file(path: &Path) -> Result<String> {
let metadata = fs::symlink_metadata(path)
.context("Cargo install tracking is missing; update this installation manually")?;
ensure!(
metadata.is_file() && !metadata.file_type().is_symlink(),
"unsafe Cargo install record"
);
let mut text = String::new();
fs::File::open(path)?
.take(MAX_RESPONSE_BYTES + 1)
.read_to_string(&mut text)?;
ensure!(
text.len() as u64 <= MAX_RESPONSE_BYTES,
"Cargo install record exceeds limit"
);
Ok(text)
}
fn cargo_install_command(
installation: &CargoInstallation,
version: &Version,
cwd: &Path,
) -> Command {
let mut command = Command::new("cargo");
command
.args(["install", "magi-code", "--locked", "--force", "--version"])
.arg(format!("={version}"))
.args([
"--bin",
"magi-code",
"--index",
"https://github.com/rust-lang/crates.io-index",
"--root",
])
.arg(&installation.root)
.arg("--target")
.arg(&installation.target)
.current_dir(cwd)
.stdin(Stdio::null())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit());
command
}
pub(crate) fn run_update() -> Result<PathBuf> {
let installation = CargoInstallation::discover(&std::env::current_exe()?)?;
let _lock = crate::persistence::CrossProcessFileLock::acquire(
&installation.root.join("magi-code-update"),
)?;
process::install_cancel_handler()?;
let installation = CargoInstallation::discover(&installation.executable)?;
let available = check_newer_version()?;
process::check_canceled()?;
let Some(version) = available else {
eprintln!("magi-code {} is up to date.", env!("CARGO_PKG_VERSION"));
return Ok(installation.executable);
};
let temporary = tempfile::Builder::new()
.prefix("magi-code-update-")
.tempdir()?;
let mut command = cargo_install_command(&installation, &version, temporary.path());
let mut cargo_probe = Command::new("cargo");
cargo_probe
.arg("--version")
.current_dir(temporary.path())
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
let cargo_available = process::run_cargo(&mut cargo_probe, CHECK_TIMEOUT)?;
ensure!(
cargo_available.success(),
"Cargo is not working; repair your Rust toolchain and retry"
);
process::check_canceled()?;
if let Err(error) = prepare_running_executable(&installation.executable, temporary.path()) {
let recovery = temporary.keep();
return Err(error.context(format!(
"update preparation failed; recovery files retained at {}",
recovery.display()
)));
}
eprintln!("Updating magi-code to {version} with Cargo. Compilation may take a while.");
let status = process::run_cargo(&mut command, Duration::from_secs(60 * 60))?;
ensure!(
status.success(),
"Cargo update failed ({status}); no success is assumed. Review Cargo's output and retry with cargo install magi-code --locked --force --root {}",
installation.root.display()
);
eprintln!("Updated magi-code to {version}.");
Ok(installation.executable)
}
#[cfg(not(windows))]
fn prepare_running_executable(_executable: &Path, _temporary: &Path) -> Result<()> {
Ok(())
}
#[cfg(windows)]
fn prepare_running_executable(executable: &Path, temporary: &Path) -> Result<()> {
let copy = temporary.join(BINARY);
fs::copy(executable, ©).context("cannot stage the running Windows executable")?;
windows::release_running_executable(executable, ©)?;
Ok(())
}
pub(crate) struct RestartContext {
pub(crate) command: Command,
pub(crate) session: Option<(PathBuf, String)>,
}
pub(crate) fn update_and_restart(mut restart: RestartContext) -> Result<()> {
if let Some((root, id)) = &restart.session {
wait_for_session_release(root, id, CHECK_TIMEOUT)?;
eprintln!(
"After the update, reopening session {id}. If it fails, use magi-code --resume {id}."
);
}
let executable = run_update()?;
let mut command = Command::new(executable);
command.args(restart.command.get_args());
if let Some(cwd) = restart.command.get_current_dir() {
command.current_dir(cwd);
}
for (key, value) in restart.command.get_envs() {
if let Some(value) = value {
command.env(key, value);
} else {
command.env_remove(key);
}
}
restart.command.env_clear();
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
Err(command.exec())
.context("updated, but could not reopen the session; use --resume with its session ID")
}
#[cfg(not(unix))]
{
let status = command
.status()
.context("updated, but could not reopen the session")?;
if !status.success() {
anyhow::bail!("restarted magi-code exited with {status}");
}
Ok(())
}
}
fn wait_for_session_release(root: &Path, id: &str, timeout: Duration) -> Result<()> {
let session = crate::sessions::SessionManager::new(root.to_path_buf()).open_existing(id)?;
let deadline = Instant::now() + timeout;
loop {
if let Some(writer) = session.try_frontend_writer()? {
drop(writer);
return Ok(());
}
ensure!(
Instant::now() < deadline,
"session {id} still has a writer; update was not started. Wait for background work to stop, then resume with --resume {id}"
);
thread::sleep(Duration::from_millis(20));
}
}
#[cfg(test)]
mod tests;