1use mj_core::hex::lower_hex;
14use std::ffi::OsString;
15use std::io::{self, BufRead, Cursor, IsTerminal, Read, Write};
16use std::path::{Path, PathBuf};
17use std::process::Command;
18use std::time::Duration;
19
20use anyhow::{Context, Result, bail, ensure};
21use chrono::Utc;
22use flate2::read::GzDecoder;
23use semver::Version;
24use serde::Deserialize;
25use sha2::{Digest, Sha256};
26
27use crate::github_watch::service::github_rate_limit_retry_at;
28
29const LATEST_RELEASE_URL: &str = "https://api.github.com/repos/BrokkAi/mjolnir/releases/latest";
30const NPM_LATEST_URL: &str = "https://registry.npmjs.org/@brokkai%2Fmjolnir/latest";
31const HOMEBREW_FORMULA_URL: &str =
32 "https://raw.githubusercontent.com/BrokkAi/homebrew-tap/main/Formula/mjolnir.rb";
33const BIN_NAME: &str = "mj";
34const WINDOWS_BIN_NAME: &str = "mj.exe";
35const VOICE_WORKER_NAME: &str = "mj-voice-worker";
36const NPM_MANAGED_ENV: &str = "MJOLNIR_MANAGED_BY_NPM";
37const NPX_MANAGED_ENV: &str = "MJOLNIR_MANAGED_BY_NPX";
38const HOMEBREW_MANAGED_ENV: &str = "MJOLNIR_MANAGED_BY_HOMEBREW";
39const NO_UPDATE_CHECK_ENV: &str = "MJOLNIR_NO_UPDATE_CHECK";
40
41#[derive(Debug, Clone)]
44struct UpdateSources {
45 latest_release: String,
46 npm_latest: String,
47 homebrew_formula: String,
48 cargo_index: String,
49}
50
51impl Default for UpdateSources {
52 fn default() -> Self {
53 Self {
54 latest_release: LATEST_RELEASE_URL.to_string(),
55 npm_latest: NPM_LATEST_URL.to_string(),
56 homebrew_formula: HOMEBREW_FORMULA_URL.to_string(),
57 cargo_index: CARGO_INDEX_URL.to_string(),
58 }
59 }
60}
61
62#[derive(Debug, Clone, PartialEq, Eq)]
67pub enum InstallMethod {
68 Npm,
69 Npx,
70 Homebrew,
71 Cargo { voice_worker: bool },
72 Direct,
73}
74
75impl InstallMethod {
76 fn current() -> Self {
80 Self::detect(
81 |name| std::env::var_os(name),
82 std::env::current_exe().ok().as_deref(),
83 )
84 }
85
86 fn detect<F>(env: F, exe: Option<&Path>) -> Self
87 where
88 F: Fn(&str) -> Option<OsString>,
89 {
90 if env(NPX_MANAGED_ENV).is_some() {
91 return Self::Npx;
92 }
93 if env(NPM_MANAGED_ENV).is_some() {
94 return Self::Npm;
95 }
96 if env(HOMEBREW_MANAGED_ENV).is_some() {
97 return Self::Homebrew;
98 }
99 exe.map_or(Self::Direct, |exe| {
100 install_method_from_exe(exe, env!("CARGO_PKG_VERSION"))
101 })
102 }
103
104 fn update_command(&self) -> Option<String> {
108 match self {
109 Self::Npm => Some("npm install -g @brokkai/mjolnir@latest".to_string()),
110 Self::Npx => Some("npx -y @brokkai/mjolnir@latest".to_string()),
111 Self::Homebrew => Some("brew upgrade --formula brokkai/tap/mjolnir".to_string()),
112 Self::Cargo { voice_worker: true } => {
113 Some("cargo install --locked brokk-mjolnir brokk-mj-voice-worker".to_string())
114 }
115 Self::Cargo {
116 voice_worker: false,
117 } => Some("cargo install --locked brokk-mjolnir".to_string()),
118 Self::Direct => None,
119 }
120 }
121
122 fn channel_name(&self) -> &'static str {
123 match self {
124 Self::Npm | Self::Npx => "npm",
125 Self::Homebrew => "Homebrew",
126 Self::Cargo { .. } => "crates.io",
127 Self::Direct => "GitHub Releases",
128 }
129 }
130}
131
132fn install_method_from_exe(exe_path: &Path, current_version: &str) -> InstallMethod {
133 if is_homebrew_executable(exe_path) {
134 return InstallMethod::Homebrew;
135 }
136 if is_npm_bundle_executable(exe_path) {
137 return InstallMethod::Npm;
138 }
139
140 let Some(install_root) = cargo_install_root(exe_path, current_version) else {
141 return InstallMethod::Direct;
142 };
143 InstallMethod::Cargo {
144 voice_worker: cargo_install_recorded(
145 &install_root,
146 "brokk-mj-voice-worker",
147 None,
148 VOICE_WORKER_NAME,
149 ),
150 }
151}
152
153fn is_homebrew_executable(exe_path: &Path) -> bool {
154 let components = path_text_components(exe_path);
155 components
156 .windows(2)
157 .any(|pair| pair == ["Cellar", "mjolnir"])
158}
159
160fn is_npm_bundle_executable(exe_path: &Path) -> bool {
165 let components = path_text_components(exe_path);
166 components
167 .windows(2)
168 .any(|pair| pair == ["node_modules", "@brokkai"])
169}
170
171fn path_text_components(exe_path: &Path) -> Vec<&str> {
172 exe_path
173 .components()
174 .filter_map(|component| component.as_os_str().to_str())
175 .collect()
176}
177
178fn cargo_install_root(exe_path: &Path, current_version: &str) -> Option<PathBuf> {
179 let canonical_exe = exe_path.canonicalize().ok()?;
180 let bin_dir = canonical_exe.parent()?;
181 if bin_dir.file_name()? != "bin" {
182 return None;
183 }
184 let install_root = bin_dir.parent()?;
185 cargo_install_recorded(
186 install_root,
187 "brokk-mjolnir",
188 Some(current_version),
189 BIN_NAME,
190 )
191 .then(|| install_root.to_path_buf())
192}
193
194fn cargo_install_recorded(
195 install_root: &Path,
196 package: &str,
197 version: Option<&str>,
198 binary: &str,
199) -> bool {
200 cargo_json_install_recorded(install_root, package, version, binary)
201 || cargo_toml_install_recorded(install_root, package, version, binary)
202}
203
204fn cargo_json_install_recorded(
205 install_root: &Path,
206 package: &str,
207 version: Option<&str>,
208 binary: &str,
209) -> bool {
210 let Ok(raw) = std::fs::read_to_string(install_root.join(".crates2.json")) else {
211 return false;
212 };
213 let Ok(manifest) = serde_json::from_str::<serde_json::Value>(&raw) else {
214 return false;
215 };
216 manifest
217 .get("installs")
218 .and_then(serde_json::Value::as_object)
219 .is_some_and(|installs| {
220 installs.iter().any(|(source, record)| {
221 cargo_source_matches(source, package, version)
222 && record
223 .get("bins")
224 .and_then(serde_json::Value::as_array)
225 .is_some_and(|bins| bins.iter().any(|name| name.as_str() == Some(binary)))
226 })
227 })
228}
229
230fn cargo_toml_install_recorded(
231 install_root: &Path,
232 package: &str,
233 version: Option<&str>,
234 binary: &str,
235) -> bool {
236 let Ok(raw) = std::fs::read_to_string(install_root.join(".crates.toml")) else {
237 return false;
238 };
239 let Ok(manifest) = raw.parse::<toml::Value>() else {
240 return false;
241 };
242 manifest
243 .get("v1")
244 .and_then(toml::Value::as_table)
245 .is_some_and(|installs| {
246 installs.iter().any(|(source, bins)| {
247 cargo_source_matches(source, package, version)
248 && bins
249 .as_array()
250 .is_some_and(|bins| bins.iter().any(|name| name.as_str() == Some(binary)))
251 })
252 })
253}
254
255fn cargo_source_matches(source: &str, package: &str, version: Option<&str>) -> bool {
256 let Some(rest) = source
257 .strip_prefix(package)
258 .and_then(|rest| rest.strip_prefix(' '))
259 else {
260 return false;
261 };
262 let Some(recorded_version) = rest.split_whitespace().next() else {
263 return false;
264 };
265 version.is_none_or(|expected| recorded_version == expected)
266}
267
268#[derive(Debug, Clone, PartialEq, Eq)]
271struct UpdateInfo {
272 version: Version,
273 tag: String,
274 asset: ReleaseAsset,
275 checksum_asset: ReleaseAsset,
276}
277
278#[derive(Debug, Clone, PartialEq, Eq)]
282enum AvailableUpdate {
283 Managed {
284 version: Version,
285 method: InstallMethod,
286 },
287 Direct(UpdateInfo),
288}
289
290#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
291struct GitHubRelease {
292 tag_name: String,
293 #[serde(default)]
294 assets: Vec<ReleaseAsset>,
295}
296
297#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
298struct ReleaseAsset {
299 name: String,
300 browser_download_url: String,
301}
302
303#[derive(Debug, Deserialize)]
304struct NpmLatest {
305 version: String,
306}
307
308#[derive(Debug, Clone, PartialEq, Eq)]
309struct Platform {
310 os_family: &'static str,
311 arch: &'static str,
312 rust_target: String,
313}
314
315async fn latest_update(
318 sources: &UpdateSources,
319 method: &InstallMethod,
320) -> Result<Option<AvailableUpdate>> {
321 let current = parse_version(env!("CARGO_PKG_VERSION"))
322 .with_context(|| format!("parse current version {}", env!("CARGO_PKG_VERSION")))?;
323 if *method == InstallMethod::Direct {
324 let release = fetch_latest_release(sources)
325 .await
326 .context("fetch latest mj release")?;
327 return update_info_from_release(&release, ¤t, ¤t_platform()?)
328 .map(|update| update.map(AvailableUpdate::Direct));
329 }
330
331 let latest = fetch_latest_managed_version(sources, method).await?;
332 Ok((latest > current).then(|| AvailableUpdate::Managed {
333 version: latest,
334 method: method.clone(),
335 }))
336}
337
338async fn fetch_latest_release(sources: &UpdateSources) -> Result<GitHubRelease> {
339 let url = &sources.latest_release;
340 let client = http_client(Duration::from_secs(5))?;
341 let mut response = client
342 .get(url)
343 .send()
344 .await
345 .with_context(|| format!("GET {url}"))?;
346 if is_github_api_url(url)
347 && github_rate_limit_retry_at(response.status(), response.headers(), Utc::now()).is_some()
348 {
349 response = retry_with_github_credential(&client, url, response.status()).await?;
350 }
351 let body = success_text(url, response).await?;
352 serde_json::from_str(&body).context("parse release body")
353}
354
355fn is_github_api_url(url: &str) -> bool {
358 reqwest::Url::parse(url)
359 .is_ok_and(|url| url.scheme() == "https" && url.host_str() == Some("api.github.com"))
360}
361
362async fn retry_with_github_credential(
369 client: &reqwest::Client,
370 url: &str,
371 anonymous_status: reqwest::StatusCode,
372) -> Result<reqwest::Response> {
373 let Some(credential) = super::backend::controller_github_credential().await else {
374 bail!(
375 "GitHub's anonymous rate limit is used up; run `gh auth login` or set GH_TOKEN \
376 so mj checks with your account (HTTP {anonymous_status})"
377 );
378 };
379 let source = credential.source;
380 let mut authorization =
381 reqwest::header::HeaderValue::from_str(&format!("Bearer {}", credential.token))
382 .with_context(|| format!("the GitHub token from {source} is not a valid header"))?;
383 authorization.set_sensitive(true);
384 let response = client
385 .get(url)
386 .header(reqwest::header::AUTHORIZATION, authorization)
387 .send()
388 .await
389 .with_context(|| format!("GET {url} with the GitHub token from {source}"))?;
390 let status = response.status();
391 if status == reqwest::StatusCode::UNAUTHORIZED {
392 bail!("GitHub rejected the token from {source}; refresh or unset it (HTTP {status})");
393 }
394 if github_rate_limit_retry_at(status, response.headers(), Utc::now()).is_some() {
395 bail!("GitHub's rate limit is used up for the token from {source} too (HTTP {status})");
396 }
397 Ok(response)
398}
399
400async fn fetch_latest_managed_version(
401 sources: &UpdateSources,
402 method: &InstallMethod,
403) -> Result<Version> {
404 match method {
405 InstallMethod::Npm | InstallMethod::Npx => {
406 let body = fetch_text(&sources.npm_latest)
407 .await
408 .context("fetch latest npm package")?;
409 let latest: NpmLatest = serde_json::from_str(&body).context("parse npm metadata")?;
410 parse_version(&latest.version).context("parse latest npm version")
411 }
412 InstallMethod::Homebrew => {
413 let body = fetch_text(&sources.homebrew_formula)
414 .await
415 .context("fetch Homebrew formula")?;
416 parse_homebrew_formula_version(&body)
417 }
418 InstallMethod::Cargo { .. } => {
419 let body = fetch_text(&sources.cargo_index)
423 .await
424 .context("fetch crates.io index entry")?;
425 parse_cargo_index_version(&body)
426 }
427 InstallMethod::Direct => anyhow::bail!("direct installs use GitHub release metadata"),
428 }
429}
430
431const CARGO_INDEX_URL: &str = "https://index.crates.io/br/ok/brokk-mjolnir";
432
433fn http_client(timeout: Duration) -> Result<reqwest::Client> {
434 reqwest::Client::builder()
435 .timeout(timeout)
436 .user_agent(concat!("mj/", env!("CARGO_PKG_VERSION")))
437 .build()
438 .context("build http client")
439}
440
441async fn fetch_text(url: &str) -> Result<String> {
442 let response = http_client(Duration::from_secs(5))?
443 .get(url)
444 .send()
445 .await
446 .with_context(|| format!("GET {url}"))?;
447 success_text(url, response).await
448}
449
450async fn success_text(url: &str, response: reqwest::Response) -> Result<String> {
451 let status = response.status();
452 ensure!(status.is_success(), "GET {url}: HTTP {status}");
453 response.text().await.with_context(|| format!("read {url}"))
454}
455
456fn parse_homebrew_formula_version(formula: &str) -> Result<Version> {
457 let raw = formula
458 .lines()
459 .map(str::trim)
460 .find_map(|line| line.strip_prefix("version \"")?.strip_suffix('"'))
461 .ok_or_else(|| anyhow::anyhow!("Homebrew formula has no version"))?;
462 parse_version(raw).context("parse Homebrew formula version")
463}
464
465fn parse_cargo_index_version(index: &str) -> Result<Version> {
466 let mut latest: Option<Version> = None;
467 for line in index.lines().filter(|line| !line.trim().is_empty()) {
468 let entry: CargoIndexEntry =
469 serde_json::from_str(line).context("parse crates.io index entry")?;
470 if entry.yanked {
471 continue;
472 }
473 let version = parse_version(&entry.vers).context("parse crates.io package version")?;
474 if latest.as_ref().is_none_or(|current| version > *current) {
475 latest = Some(version);
476 }
477 }
478 latest.ok_or_else(|| anyhow::anyhow!("crates.io index has no published versions"))
479}
480
481#[derive(Debug, Deserialize)]
482struct CargoIndexEntry {
483 vers: String,
484 #[serde(default)]
485 yanked: bool,
486}
487
488fn update_info_from_release(
489 release: &GitHubRelease,
490 current: &Version,
491 platform: &Platform,
492) -> Result<Option<UpdateInfo>> {
493 let latest = parse_version(&release.tag_name)
494 .with_context(|| format!("parse release tag {}", release.tag_name))?;
495 if latest <= *current {
496 return Ok(None);
497 }
498
499 let asset = select_mj_asset(&release.assets, platform)
500 .with_context(|| format!("find mj asset for {}/{}", platform.os_family, platform.arch))?;
501 let checksum_name = format!("{}.sha256", asset.name);
502 let checksum_asset = release
503 .assets
504 .iter()
505 .find(|candidate| candidate.name == checksum_name)
506 .cloned()
507 .ok_or_else(|| {
508 anyhow::anyhow!(
509 "release {} is missing required checksum asset {}",
510 release.tag_name,
511 checksum_name
512 )
513 })?;
514
515 Ok(Some(UpdateInfo {
516 version: latest,
517 tag: release.tag_name.clone(),
518 asset,
519 checksum_asset,
520 }))
521}
522
523fn select_mj_asset(assets: &[ReleaseAsset], platform: &Platform) -> Result<ReleaseAsset> {
524 let target_suffix = format!(
525 "-{}{}",
526 platform.rust_target,
527 platform_archive_ext(platform)
528 );
529 assets
530 .iter()
531 .find(|asset| is_mj_archive(&asset.name) && asset.name.ends_with(&target_suffix))
532 .cloned()
533 .ok_or_else(|| {
534 anyhow::anyhow!(
535 "no mj archive found for target {}; available assets: {}",
536 platform.rust_target,
537 assets
538 .iter()
539 .filter(|asset| !asset.name.ends_with(".sha256"))
540 .map(|asset| asset.name.as_str())
541 .collect::<Vec<_>>()
542 .join(", ")
543 )
544 })
545}
546
547fn is_mj_archive(name: &str) -> bool {
548 name.starts_with("brokk-mjolnir-") && (name.ends_with(".tar.gz") || name.ends_with(".zip"))
549}
550
551fn platform_archive_ext(platform: &Platform) -> &'static str {
552 if platform.os_family == "windows" {
553 ".zip"
554 } else {
555 ".tar.gz"
556 }
557}
558
559fn current_platform() -> Result<Platform> {
560 let arch = match std::env::consts::ARCH {
561 "x86_64" => "x86_64",
562 "aarch64" | "arm64" => "aarch64",
563 other => anyhow::bail!("unsupported CPU architecture: {other}"),
564 };
565 let (os_family, rust_os) = match std::env::consts::OS {
566 "android" => ("android", "linux-android"),
567 "macos" => ("macos", "apple-darwin"),
568 "linux" => ("linux", "unknown-linux-gnu"),
569 "windows" => ("windows", "pc-windows-msvc"),
570 other => anyhow::bail!("unsupported OS: {other}"),
571 };
572
573 Ok(Platform {
574 os_family,
575 arch,
576 rust_target: format!("{arch}-{rust_os}"),
577 })
578}
579
580fn parse_version(raw: &str) -> Result<Version> {
581 Version::parse(raw.trim_start_matches('v')).with_context(|| format!("parse version {raw}"))
582}
583
584#[derive(Debug, Clone, PartialEq, Eq)]
587pub enum StartupUpdateOutcome {
588 Skipped,
590 UpToDate,
591 Notified(String),
594 Declined,
595 Failed(String),
598}
599
600#[derive(Debug, Clone, PartialEq, Eq)]
604pub enum StartupUpdateNotice {
605 Available(String),
606 Failed(String),
607}
608
609impl StartupUpdateOutcome {
610 fn failed(what: &str, error: &anyhow::Error) -> Self {
614 tracing::warn!(error = format!("{error:#}"), "{what} failed");
615 Self::Failed(format!(
616 "{what} failed: {}. Still running mj {}.",
617 error.root_cause(),
618 env!("CARGO_PKG_VERSION")
619 ))
620 }
621
622 pub fn dashboard_notice(self) -> Option<StartupUpdateNotice> {
623 match self {
624 Self::Notified(notice) => Some(StartupUpdateNotice::Available(notice)),
625 Self::Failed(notice) => Some(StartupUpdateNotice::Failed(notice)),
626 Self::Skipped | Self::UpToDate | Self::Declined => None,
627 }
628 }
629}
630
631pub async fn check_prompt_and_apply() -> StartupUpdateOutcome {
648 if cfg!(windows)
652 || cfg!(debug_assertions)
653 || !io::stdin().is_terminal()
654 || !io::stdout().is_terminal()
655 || std::env::var_os(NO_UPDATE_CHECK_ENV).is_some()
656 {
657 return StartupUpdateOutcome::Skipped;
658 }
659
660 let method = InstallMethod::current();
661
662 let update = match latest_update(&UpdateSources::default(), &method).await {
663 Ok(Some(update)) => update,
664 Ok(None) => return StartupUpdateOutcome::UpToDate,
665 Err(error) => return StartupUpdateOutcome::failed("Update check", &error),
667 };
668
669 match update {
670 AvailableUpdate::Direct(update) => {
671 match prompt_for_update(&update.version, &InstallMethod::Direct) {
672 Ok(true) => {}
673 Ok(false) => return StartupUpdateOutcome::Declined,
674 Err(error) => return StartupUpdateOutcome::failed("Upgrade prompt", &error),
675 }
676 match download_apply_and_restart(&update).await {
679 Ok(()) => StartupUpdateOutcome::Skipped,
680 Err(error) => StartupUpdateOutcome::failed("Upgrade", &error),
681 }
682 }
683 AvailableUpdate::Managed { version, method } => match method {
684 InstallMethod::Npm | InstallMethod::Homebrew => {
685 match prompt_for_update(&version, &method) {
686 Ok(true) => {}
687 Ok(false) => return StartupUpdateOutcome::Declined,
688 Err(error) => return StartupUpdateOutcome::failed("Upgrade prompt", &error),
689 }
690 let upgraded =
691 run_managed_upgrade(&version, &method).and_then(restart_current_process);
692 match upgraded {
693 Ok(()) => StartupUpdateOutcome::Skipped,
694 Err(error) => StartupUpdateOutcome::failed("Upgrade", &error),
695 }
696 }
697 InstallMethod::Npx | InstallMethod::Cargo { .. } => StartupUpdateOutcome::Notified(
698 managed_update_notice(&version, &method, env!("CARGO_PKG_VERSION"))
699 .expect("notice-only channels have an update command"),
700 ),
701 InstallMethod::Direct => {
702 unreachable!("direct installs never report managed updates")
703 }
704 },
705 }
706}
707
708fn prompt_for_update(version: &Version, method: &InstallMethod) -> Result<bool> {
709 print!(
710 "mj {version} is available through {}; current version is {}. Upgrade now? [Y/n] ",
711 method.channel_name(),
712 env!("CARGO_PKG_VERSION")
713 );
714 io::stdout().flush().context("flush update prompt")?;
715
716 read_update_answer(&mut io::stdin().lock())
717}
718
719fn read_update_answer(input: &mut impl BufRead) -> Result<bool> {
720 let mut answer = String::new();
721 let bytes_read = input
722 .read_line(&mut answer)
723 .context("read update prompt answer")?;
724 Ok(bytes_read != 0 && prompt_answer_is_yes(&answer))
725}
726
727fn prompt_answer_is_yes(answer: &str) -> bool {
729 matches!(answer.trim(), "" | "y" | "Y" | "yes" | "YES")
730}
731
732fn managed_update_notice(
733 version: &Version,
734 method: &InstallMethod,
735 current_version: &str,
736) -> Option<String> {
737 Some(format!(
738 "mj {version} is available through {}; current version is {current_version}. Run: {}",
739 method.channel_name(),
740 method.update_command()?
741 ))
742}
743
744fn npm_upgrade_command() -> Command {
745 let mut command = Command::new("npm");
746 command.args(["install", "-g", "@brokkai/mjolnir@latest"]);
747 command
748}
749
750fn brew_update_command() -> Command {
751 let mut command = Command::new("brew");
752 command.arg("update");
753 command
754}
755
756fn brew_upgrade_command() -> Command {
757 let mut command = Command::new("brew");
758 command.args(["upgrade", "--formula", "brokkai/tap/mjolnir"]);
761 command
762}
763
764fn run_managed_upgrade(version: &Version, method: &InstallMethod) -> Result<RestartTarget> {
768 let current_exe = std::env::current_exe().context("resolve current executable")?;
771 let restart = managed_restart_target(method, ¤t_exe)?;
772 match method {
773 InstallMethod::Npm => {
774 println!("mj: running npm install -g @brokkai/mjolnir@latest");
775 let status = mj_core::subprocess::run_inherited(&mut npm_upgrade_command())
776 .context("run npm install -g @brokkai/mjolnir@latest")?;
777 ensure!(
778 status.success(),
779 "npm install exited with {status}; npm usually explains why above"
780 );
781 }
782 InstallMethod::Homebrew => {
783 println!("mj: running brew update");
788 let status = mj_core::subprocess::run_inherited(&mut brew_update_command())
789 .context("run brew update")?;
790 ensure!(status.success(), "brew update exited with {status}");
791 println!("mj: running brew upgrade --formula brokkai/tap/mjolnir");
792 let status = mj_core::subprocess::run_inherited(&mut brew_upgrade_command())
793 .context("run brew upgrade --formula brokkai/tap/mjolnir")?;
794 ensure!(status.success(), "brew upgrade exited with {status}");
795 }
796 other => bail!("{other:?} installs do not support delegated upgrades"),
797 }
798 println!("mj: upgraded to {version}; restarting");
799 Ok(restart)
800}
801
802#[derive(Debug, Clone, PartialEq, Eq)]
804enum RestartTarget {
805 SameExe(PathBuf),
809 Wrapper,
814}
815
816fn managed_restart_target(method: &InstallMethod, current_exe: &Path) -> Result<RestartTarget> {
817 match method {
818 InstallMethod::Npm => Ok(RestartTarget::SameExe(current_exe.to_path_buf())),
819 InstallMethod::Homebrew => Ok(RestartTarget::Wrapper),
820 other => bail!("{other:?} installs do not support delegated upgrades"),
821 }
822}
823
824#[cfg(unix)]
825fn restart_current_process(target: RestartTarget) -> Result<()> {
826 use std::os::unix::process::CommandExt;
827
828 let args: Vec<OsString> = std::env::args_os().skip(1).collect();
829 let mut command = match target {
830 RestartTarget::SameExe(exe) => Command::new(exe),
831 RestartTarget::Wrapper => Command::new("mj"),
832 };
833 let error = command.args(args).exec();
834 Err(error).context("exec replacement mj")
835}
836
837#[cfg(not(unix))]
838fn restart_current_process(_target: RestartTarget) -> Result<()> {
839 bail!("automatic restart is only supported on Unix platforms")
840}
841
842async fn download_apply_and_restart(update: &UpdateInfo) -> Result<()> {
843 println!("mj: downloading {} ({})", update.tag, update.asset.name);
844 let archive = download_bytes(&update.asset.browser_download_url)
845 .await
846 .with_context(|| format!("download {}", update.asset.name))?;
847 verify_checksum(update, &archive).await?;
848
849 let current_exe = std::env::current_exe().context("resolve current executable")?;
850 let replacement = install_release_archive(¤t_exe, &update.asset.name, &archive)
851 .context("install release bundle")?;
852
853 println!("mj: upgraded to {}; restarting", update.tag);
854 restart_current_process(RestartTarget::SameExe(replacement))
855}
856
857async fn download_bytes(url: &str) -> Result<Vec<u8>> {
858 let resp = http_client(Duration::from_secs(120))?
859 .get(url)
860 .send()
861 .await
862 .with_context(|| format!("GET {url}"))?;
863 let status = resp.status();
864 if !status.is_success() {
865 anyhow::bail!("GET {url}: HTTP {status}");
866 }
867 resp.bytes()
868 .await
869 .map(|bytes| bytes.to_vec())
870 .context("read response body")
871}
872
873async fn verify_checksum(update: &UpdateInfo, archive: &[u8]) -> Result<()> {
874 let body = download_bytes(&update.checksum_asset.browser_download_url)
875 .await
876 .with_context(|| format!("download {}", update.checksum_asset.name))?;
877 let body = String::from_utf8(body).context("checksum file is not utf-8")?;
878 let expected = body
879 .split_whitespace()
880 .next()
881 .ok_or_else(|| anyhow::anyhow!("empty checksum file {}", update.checksum_asset.name))?;
882 let actual = sha256_hex(archive);
883 if expected != actual {
884 bail!(
885 "checksum mismatch for {}: expected {expected}, got {actual}",
886 update.asset.name
887 );
888 }
889 Ok(())
890}
891
892fn sha256_hex(bytes: &[u8]) -> String {
893 let mut hasher = Sha256::new();
894 hasher.update(bytes);
895 lower_hex(hasher.finalize())
896}
897
898fn install_release_archive(
901 current_exe: &Path,
902 archive_name: &str,
903 archive_bytes: &[u8],
904) -> Result<PathBuf> {
905 ensure!(
906 cfg!(unix),
907 "self-update replacement is only supported on Unix platforms"
908 );
909 let target_exe = current_exe
910 .canonicalize()
911 .with_context(|| format!("resolve executable target {}", current_exe.display()))?;
912 let parent = target_exe
913 .parent()
914 .ok_or_else(|| anyhow::anyhow!("executable has no parent: {}", target_exe.display()))?;
915 let staging = tempfile::Builder::new()
918 .prefix(".mj-self-update-")
919 .tempdir_in(parent)
920 .context("create update staging directory")?;
921 let mut binaries = stage_release_archive(archive_name, archive_bytes, staging.path())?;
922 let executable_name = if archive_name.ends_with(".zip") {
923 WINDOWS_BIN_NAME
924 } else {
925 BIN_NAME
926 };
927 ensure!(
928 staging.path().join(executable_name).is_file(),
929 "archive did not contain expected binary: {executable_name}"
930 );
931 binaries.sort_by_key(|path| path.file_name() == Some(executable_name.as_ref()));
933 strip_quarantine(staging.path());
934 for binary in binaries {
935 let name = binary
936 .file_name()
937 .context("staged binary has no file name")?;
938 let target = if name == executable_name {
939 target_exe.clone()
940 } else {
941 parent.join(name)
942 };
943 std::fs::rename(&binary, &target)
944 .with_context(|| format!("install {}", target.display()))?;
945 }
946 Ok(target_exe)
947}
948
949fn stage_release_archive(
950 archive_name: &str,
951 archive_bytes: &[u8],
952 directory: &Path,
953) -> Result<Vec<PathBuf>> {
954 let mut binaries = Vec::new();
955 if archive_name.ends_with(".zip") {
956 let mut archive =
957 zip::ZipArchive::new(Cursor::new(archive_bytes)).context("open zip archive")?;
958 for index in 0..archive.len() {
959 let mut entry = archive.by_index(index).context("read zip entry")?;
960 let path = entry.enclosed_name().ok_or_else(|| {
961 anyhow::anyhow!("zip entry escapes destination: {}", entry.name())
962 })?;
963 let is_file = entry.is_file() && !entry.is_symlink();
964 if let Some(binary) = stage_archive_binary(directory, &path, is_file, &mut entry)? {
965 binaries.push(binary);
966 }
967 }
968 } else {
969 let mut archive = tar::Archive::new(GzDecoder::new(archive_bytes));
970 for entry in archive.entries().context("read tar entries")? {
971 let mut entry = entry.context("read tar entry")?;
972 let path = entry.path().context("read tar entry path")?.into_owned();
973 let is_file = entry.header().entry_type().is_file();
974 if let Some(binary) = stage_archive_binary(directory, &path, is_file, &mut entry)? {
975 binaries.push(binary);
976 }
977 }
978 }
979 Ok(binaries)
980}
981
982fn stage_archive_binary(
983 directory: &Path,
984 path: &Path,
985 is_file: bool,
986 mut contents: impl Read,
987) -> Result<Option<PathBuf>> {
988 let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
989 return Ok(None);
990 };
991 let stem = name.strip_suffix(".exe").unwrap_or(name);
992 if !matches!(
993 stem,
994 BIN_NAME | "mj-desktop" | VOICE_WORKER_NAME | "mj-worker"
995 ) && !stem.starts_with("mj-worker-")
996 {
997 return Ok(None);
998 }
999 ensure!(
1000 is_file,
1001 "archive binary is not a regular file: {}",
1002 path.display()
1003 );
1004 let target = directory.join(name);
1005 let mut output = std::fs::File::create_new(&target)
1006 .with_context(|| format!("stage {name}; each binary must appear only once"))?;
1007 let size = io::copy(&mut contents, &mut output).with_context(|| format!("extract {name}"))?;
1008 ensure!(size != 0, "archive contained an empty {name} binary");
1009 #[cfg(unix)]
1010 {
1011 use std::os::unix::fs::PermissionsExt;
1012 output
1013 .set_permissions(std::fs::Permissions::from_mode(0o755))
1014 .with_context(|| format!("chmod {name}"))?;
1015 }
1016 Ok(Some(target))
1017}
1018
1019#[cfg(unix)]
1020fn strip_quarantine(path: &Path) {
1021 #[cfg(target_os = "macos")]
1022 {
1023 let _ = Command::new("xattr")
1026 .arg("-dr")
1027 .arg("com.apple.quarantine")
1028 .arg(path)
1029 .status();
1030 }
1031 #[cfg(not(target_os = "macos"))]
1032 {
1033 let _ = path;
1034 }
1035}
1036
1037#[cfg(not(unix))]
1038fn strip_quarantine(_path: &Path) {}
1039
1040#[cfg(test)]
1041mod tests;