Skip to content

Commit 8369a11

Browse files
nbafrankclaude
andcommitted
Release v0.2.20: uvr upgrade command + R 4.6 install fix on macOS
New feature - `uvr upgrade` (alias `uvr self-update`) checks GitHub releases and installs the latest binary in place. `--check` reports availability without downloading. Fixes - macOS R 4.6 install was unusable: CRAN ships 4.6 with hardened-runtime signing on bin/exec/R and load commands pointing at the framework path, so dyld stripped DYLD_LIBRARY_PATH and SIGKILL'd the process at startup. New `patch_r_executables` rewrites bin/exec/R load commands to point at our managed lib/libR.dylib and re-signs ad-hoc (which clears the runtime flag). `fix_libr_install_name` now also re-signs unconditionally for the same reason. Sync's retroactive-patch path runs the same fix on existing installs, so an `uvr sync` against a previously-broken 4.6 install in a uvr project will repair it without needing `uvr r uninstall && r install`. - `find_r_binary`'s "any managed installation" fallback now validates each candidate via `query_r_version` and skips broken installs in version-descending order. Fixes `uvr r pin` (no arg) erroring out when the alphabetically-first managed R happens to be unusable. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ed1a50e commit 8369a11

9 files changed

Lines changed: 168 additions & 47 deletions

File tree

Cargo.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ members = ["crates/uvr", "crates/uvr-core"]
33
resolver = "2"
44

55
[workspace.package]
6-
version = "0.2.19"
6+
version = "0.2.20"
77
edition = "2021"
88
authors = ["uvr contributors"]
99
license = "MIT"

crates/uvr-core/src/r_version/detector.rs

Lines changed: 27 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -141,17 +141,25 @@ pub fn find_r_binary(version_constraint: Option<&str>) -> Result<PathBuf> {
141141
}
142142

143143
// 3. Prefer managed installation, fall back to first system R.
144-
// Use the already-fetched list — do NOT call find_all() again.
145-
let system_fallback = installations
146-
.iter()
147-
.find(|i| !i.managed)
148-
.map(|i| i.binary.clone());
149-
installations
150-
.into_iter()
151-
.find(|i| i.managed)
152-
.map(|i| i.binary)
153-
.or(system_fallback)
154-
.ok_or(UvrError::RNotFound)
144+
// Validate each candidate via `query_r_version` so a broken managed
145+
// install (e.g. R 4.6 before the install-name patch on macOS — see
146+
// `patch_r_executables`) doesn't silently capture every uvr command.
147+
let mut managed: Vec<&RInstallation> = installations.iter().filter(|i| i.managed).collect();
148+
let mut system: Vec<&RInstallation> = installations.iter().filter(|i| !i.managed).collect();
149+
// Probe in version-descending order so the newest working install wins.
150+
managed.sort_by(|a, b| version_cmp(&b.version, &a.version));
151+
system.sort_by(|a, b| version_cmp(&b.version, &a.version));
152+
for inst in managed.into_iter().chain(system.into_iter()) {
153+
if query_r_version(&inst.binary).is_some() {
154+
return Ok(inst.binary.clone());
155+
}
156+
}
157+
Err(UvrError::RNotFound)
158+
}
159+
160+
fn version_cmp(a: &str, b: &str) -> std::cmp::Ordering {
161+
let parse = |s: &str| -> Vec<u32> { s.split('.').filter_map(|p| p.parse().ok()).collect() };
162+
parse(a).cmp(&parse(b))
155163
}
156164

157165
fn find_exact_version(installations: &[RInstallation], version: &str) -> Result<PathBuf> {
@@ -192,20 +200,14 @@ pub fn query_r_version(binary: &std::path::Path) -> Option<String> {
192200
// before user code runs. The version string from our `-e` script is always
193201
// the last line, so pick the last non-empty line that parses as a version.
194202
let stdout = String::from_utf8_lossy(&output.stdout);
195-
stdout
196-
.lines()
197-
.rev()
198-
.find_map(|line| {
199-
let t = line.trim();
200-
if !t.is_empty()
201-
&& t.chars().all(|c| c.is_ascii_digit() || c == '.')
202-
&& t.contains('.')
203-
{
204-
Some(t.to_string())
205-
} else {
206-
None
207-
}
208-
})
203+
stdout.lines().rev().find_map(|line| {
204+
let t = line.trim();
205+
if !t.is_empty() && t.chars().all(|c| c.is_ascii_digit() || c == '.') && t.contains('.') {
206+
Some(t.to_string())
207+
} else {
208+
None
209+
}
210+
})
209211
}
210212

211213
#[cfg(test)]

crates/uvr-core/src/r_version/downloader.rs

Lines changed: 100 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -442,6 +442,13 @@ fn install_r_macos(pkg_bytes: &[u8], version: &str, dest: &Path) -> Result<()> {
442442
// to load with "Symbol not found: _dgebal_".
443443
patch_r_dylibs(dest);
444444

445+
// Step 9(a.5): patch bin/exec/R (and siblings). R 4.6+ ships these with
446+
// hardened-runtime signatures pointing at the framework path; without
447+
// rewriting those load commands and re-signing ad-hoc, dyld can't find
448+
// libR.dylib and the process is SIGKILLed at startup. (Pre-4.6 used
449+
// ad-hoc signing so DYLD_LIBRARY_PATH was enough — 4.6 changed that.)
450+
patch_r_executables(dest);
451+
445452
// Step 9(b): write etc/Renviron.site so that DYLD_LIBRARY_PATH is set for every
446453
// R process this installation spawns — including the fresh `R --slave` sessions
447454
// used by R CMD INSTALL for byte-compilation. On macOS 15+ (SIP), DYLD_*
@@ -533,30 +540,110 @@ fn patch_makeconf_libr(dest: &Path) -> Result<()> {
533540
/// packages compiled against it can find it at runtime without DYLD_LIBRARY_PATH.
534541
///
535542
/// IMPORTANT: `install_name_tool` invalidates the Mach-O code signature.
536-
/// On Apple Silicon every dylib must be signed; we re-apply an ad-hoc signature
537-
/// with `codesign --force --sign -` immediately after the rename.
543+
/// On Apple Silicon every dylib must be signed; we always re-apply an ad-hoc
544+
/// signature afterwards. The ad-hoc re-sign also strips the original hardened
545+
/// runtime flag (set by CRAN's Developer ID signing on R 4.6+) — without that
546+
/// strip, macOS would refuse to load the dylib through DYLD_LIBRARY_PATH.
538547
fn fix_libr_install_name(dest: &Path) {
539548
let libr = dest.join("lib").join("libR.dylib");
540549
if !libr.exists() {
541550
return;
542551
}
543552
let new_id = libr.to_string_lossy().to_string();
544-
545-
let ok = Command::new("install_name_tool")
553+
let _ = Command::new("install_name_tool")
546554
.args(["-id", &new_id, &new_id])
547-
.status()
548-
.map(|s| s.success())
549-
.unwrap_or(false);
555+
.status();
556+
// Always re-sign — even if install_name_tool was a no-op, the original
557+
// CRAN signature has the hardened runtime flag set on R 4.6+, which makes
558+
// DYLD_LIBRARY_PATH ineffective for the executables that load this dylib.
559+
resign_adhoc(&libr);
560+
}
550561

551-
if ok {
552-
// Re-sign with an ad-hoc signature after modifying the binary.
553-
// Without this, macOS (arm64) kills any process that loads the dylib.
554-
let _ = Command::new("codesign")
555-
.args(["--force", "--sign", "-", &new_id])
556-
.status();
562+
/// Patch executable Mach-O binaries under `<r_home>/bin/exec/` so they load
563+
/// our managed `lib/libR.dylib` instead of the framework path baked in by CRAN.
564+
///
565+
/// Without this, R 4.6+ (which CRAN signs with hardened runtime) silently
566+
/// SIGKILLs at startup because:
567+
/// 1. `bin/exec/R` has a load command for `/Library/Frameworks/R.framework/Versions/4.6/Resources/lib/libR.dylib`.
568+
/// 2. The framework path doesn't exist in our extracted install.
569+
/// 3. Hardened runtime causes macOS to strip `DYLD_LIBRARY_PATH`, so the
570+
/// `Renviron.site` hint we set has no effect on the dyld lookup.
571+
///
572+
/// Fix: rewrite the load command to point at our `lib/libR.dylib`, then re-sign
573+
/// ad-hoc (which also clears the hardened-runtime flag).
574+
pub fn patch_r_executables(r_home: &Path) {
575+
let exec_dir = r_home.join("bin").join("exec");
576+
if !exec_dir.exists() {
577+
return;
578+
}
579+
let lib_dir = r_home.join("lib");
580+
let Ok(entries) = std::fs::read_dir(&exec_dir) else {
581+
return;
582+
};
583+
for entry in entries.flatten() {
584+
let path = entry.path();
585+
if !path.is_file() {
586+
continue;
587+
}
588+
rewrite_framework_loads(&path, &lib_dir);
557589
}
558590
}
559591

592+
/// Rewrite every `/Library/Frameworks/R.framework/...` load command in `binary`
593+
/// to point at the corresponding file in `lib_dir` (matched by basename), then
594+
/// re-sign ad-hoc. No-op when otool reports no framework references.
595+
fn rewrite_framework_loads(binary: &Path, lib_dir: &Path) {
596+
let path_str = binary.to_string_lossy().to_string();
597+
let Ok(deps_out) = Command::new("otool").args(["-L", &path_str]).output() else {
598+
return;
599+
};
600+
if !deps_out.status.success() {
601+
return;
602+
}
603+
let Ok(deps) = String::from_utf8(deps_out.stdout) else {
604+
return;
605+
};
606+
let mut changed = false;
607+
for line in deps.lines().skip(1) {
608+
let dep = line.split_whitespace().next().unwrap_or("");
609+
if !dep.contains("/Library/Frameworks/R.framework/") {
610+
continue;
611+
}
612+
let Some(filename) = std::path::Path::new(dep).file_name() else {
613+
continue;
614+
};
615+
let new_dep = lib_dir.join(filename);
616+
if !new_dep.exists() {
617+
continue;
618+
}
619+
let new_dep_str = new_dep.to_string_lossy().to_string();
620+
if Command::new("install_name_tool")
621+
.args(["-change", dep, &new_dep_str, &path_str])
622+
.status()
623+
.map(|s| s.success())
624+
.unwrap_or(false)
625+
{
626+
changed = true;
627+
}
628+
}
629+
if changed {
630+
resign_adhoc(binary);
631+
} else {
632+
// Even when install_name_tool was a no-op, R 4.6+ has hardened runtime
633+
// on bin/exec/R that prevents DYLD_LIBRARY_PATH from rescuing the load.
634+
// Re-sign defensively to drop the runtime flag.
635+
resign_adhoc(binary);
636+
}
637+
}
638+
639+
/// Re-sign a Mach-O ad-hoc, clearing any prior hardened-runtime flag.
640+
fn resign_adhoc(path: &Path) {
641+
let path_str = path.to_string_lossy().to_string();
642+
let _ = Command::new("codesign")
643+
.args(["--force", "--sign", "-", &path_str])
644+
.status();
645+
}
646+
560647
/// Patch all `.dylib` install names in `<r_home>/lib/` to use the managed-R
561648
/// path instead of the original CRAN framework path.
562649
///

crates/uvr/src/cli.rs

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,9 @@ pub enum Commands {
7272
/// Generate shell completions
7373
Completions(CompletionsArgs),
7474

75-
/// Update uvr itself to the latest release
76-
SelfUpdate,
75+
/// Check for and install the latest uvr release
76+
#[command(aliases = ["self-update"])]
77+
Upgrade(UpgradeArgs),
7778

7879
/// Manage R versions
7980
#[command(name = "r")]
@@ -342,6 +343,18 @@ pub struct RPinArgs {
342343
pub version: Option<String>,
343344
}
344345

346+
// ────────────────────────────────────────────────────────────
347+
// upgrade
348+
// ────────────────────────────────────────────────────────────
349+
350+
#[derive(Debug, Args)]
351+
pub struct UpgradeArgs {
352+
/// Print the latest version and whether an update is available, without
353+
/// downloading or installing anything.
354+
#[arg(long)]
355+
pub check: bool,
356+
}
357+
345358
// ────────────────────────────────────────────────────────────
346359
// cache
347360
// ────────────────────────────────────────────────────────────

crates/uvr/src/commands/self_update.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ use uvr_core::r_version::downloader::Platform;
55
use crate::ui;
66
use crate::ui::palette;
77

8-
pub async fn run() -> Result<()> {
8+
pub async fn run(check_only: bool) -> Result<()> {
99
let current = env!("CARGO_PKG_VERSION");
1010
ui::info(format!("Checking for updates (current: v{current})"));
1111

@@ -26,6 +26,11 @@ pub async fn run() -> Result<()> {
2626
palette::upgraded(format!("v{latest}")),
2727
);
2828

29+
if check_only {
30+
ui::hint("Run `uvr upgrade` to install. Skipping download (--check).");
31+
return Ok(());
32+
}
33+
2934
let target = Platform::detect()
3035
.map(|p| p.rust_target_triple())
3136
.unwrap_or("unknown");

crates/uvr/src/commands/sync.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ use uvr_core::installer::r_cmd_install::RCmdInstall;
1010
use uvr_core::lockfile::{LockedPackage, Lockfile};
1111
use uvr_core::project::Project;
1212
use uvr_core::r_version::detector::{find_r_binary, query_r_version};
13-
use uvr_core::r_version::downloader::{patch_r_dylibs, patch_renviron_site, Platform};
13+
use uvr_core::r_version::downloader::{
14+
patch_r_dylibs, patch_r_executables, patch_renviron_site, Platform,
15+
};
1416
use uvr_core::registry::p3m::P3MBinaryIndex;
1517
use uvr_core::resolver::topological_install_order;
1618

@@ -335,6 +337,7 @@ pub async fn install_from_lockfile(
335337
if cfg!(target_os = "macos") {
336338
let _ = patch_renviron_site(r_home);
337339
patch_r_dylibs(r_home);
340+
patch_r_executables(r_home);
338341
}
339342
let libr_name = if cfg!(target_os = "macos") {
340343
"libR.dylib"

crates/uvr/src/main.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,8 +123,8 @@ async fn run() -> Result<()> {
123123
Commands::Completions(args) => {
124124
commands::completions::run(args.shell)?;
125125
}
126-
Commands::SelfUpdate => {
127-
commands::self_update::run().await?;
126+
Commands::Upgrade(args) => {
127+
commands::self_update::run(args.check).await?;
128128
}
129129
Commands::Doctor => {
130130
commands::doctor::run()?;

crates/uvr/tests/cli_tests.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,17 @@ fn test_add_help_works() {
120120
uvr_cmd().args(["add", "--help"]).assert().success();
121121
}
122122

123+
#[test]
124+
fn test_upgrade_help_works() {
125+
uvr_cmd().args(["upgrade", "--help"]).assert().success();
126+
}
127+
128+
#[test]
129+
fn test_self_update_alias_works() {
130+
// Backward-compat: `uvr self-update` is a hidden alias for `uvr upgrade`.
131+
uvr_cmd().args(["self-update", "--help"]).assert().success();
132+
}
133+
123134
#[test]
124135
fn test_r_use_exact_writes_r_version_file() {
125136
let dir = init_project("pin-test");

0 commit comments

Comments
 (0)