diff --git a/crates/ide-sdk/src/discover.rs b/crates/ide-sdk/src/discover.rs index 903682d31..439971472 100644 --- a/crates/ide-sdk/src/discover.rs +++ b/crates/ide-sdk/src/discover.rs @@ -394,27 +394,33 @@ fn release_value(release: &str, key: &str) -> Option { } fn jdk() -> Vec { + crate::java::rescan_jdks().iter().map(jdk_sdk).collect() +} + +/// A JDK as the SDK pickers list it (`Eclipse Adoptium 25.0.1`). +pub fn jdk_sdk(j: &crate::java::Jdk) -> Sdk { let java_home = std::env::var_os("JAVA_HOME").and_then(|h| dunce::canonicalize(h).ok()); - crate::java::rescan_jdks() - .iter() - .map(|j| { - let release = std::fs::read_to_string(j.home.join("release")).unwrap_or_default(); - let version = release_value(&release, "JAVA_VERSION").unwrap_or_else(|| j.major.to_string()); - let name = match release_value(&release, "IMPLEMENTOR") { - Some(vendor) => format!("{vendor} {version}"), - None => format!("JDK {version}"), - }; - let source = if java_home.as_ref() == Some(&j.home) { "JAVA_HOME" } else { "system" }; - Sdk { - path: j.home.clone().into(), - name, - version: Some(version), - source: source.to_owned(), - home: Some(j.home.clone().into()), - extra: None, - } - }) - .collect() + let release = std::fs::read_to_string(j.home.join("release")).unwrap_or_default(); + let version = release_value(&release, "JAVA_VERSION").unwrap_or_else(|| j.major.to_string()); + let name = match release_value(&release, "IMPLEMENTOR") { + Some(vendor) => format!("{vendor} {version}"), + None => format!("JDK {version}"), + }; + let source = if java_home.as_ref() == Some(&j.home) { + "JAVA_HOME" + } else if crate::java::is_downloaded(&j.home) { + "downloaded" + } else { + "system" + }; + Sdk { + path: j.home.clone().into(), + name, + version: Some(version), + source: source.to_owned(), + home: Some(j.home.clone().into()), + extra: None, + } } // ------------------------------------------------------------------ maven diff --git a/crates/ide-sdk/src/java.rs b/crates/ide-sdk/src/java.rs index c021612c9..163427cb1 100644 --- a/crates/ide-sdk/src/java.rs +++ b/crates/ide-sdk/src/java.rs @@ -49,9 +49,26 @@ fn children(dir: &Path) -> Vec { const WINDOWS_VENDORS: &[&str] = &["Java", "Eclipse Adoptium", "Microsoft", "Zulu", "Amazon Corretto", "BellSoft", "Semeru", "OpenJDK"]; +/// Folders the IDE downloads JDKs into ([`add_jdk_root`]). +static EXTRA_ROOTS: Mutex> = Mutex::new(Vec::new()); + +/// Scans the JDKs in `dir` (one per subfolder) too, e.g. where Download JDK puts them. +pub fn add_jdk_root(dir: &Path) { + let mut roots = EXTRA_ROOTS.lock().unwrap_or_else(|e| e.into_inner()); + if !roots.iter().any(|r| r == dir) { + roots.push(dir.to_path_buf()); + } +} + +/// Whether `home` lies in a folder registered with [`add_jdk_root`]. +pub fn is_downloaded(home: &Path) -> bool { + let roots = EXTRA_ROOTS.lock().unwrap_or_else(|e| e.into_inner()); + roots.iter().any(|r| home.starts_with(r) || dunce::canonicalize(r).is_ok_and(|r| home.starts_with(r))) +} + /// JDK homes in the usual install locations (macOS `JavaVirtualMachines`, /// `/usr/lib/jvm`, SDKMAN, IDEA's `~/.jdks`, Homebrew, Windows vendor -/// directories), `JAVA_HOME` first, then the `java` on `PATH`. +/// directories, the IDE's downloads), `JAVA_HOME` first, then the `java` on `PATH`. fn scan() -> Vec { let mut homes: Vec = Vec::new(); if let Some(h) = std::env::var_os("JAVA_HOME") { @@ -74,6 +91,7 @@ fn scan() -> Vec { if let Some(h) = &home { roots.extend([h.join("Library/Java/JavaVirtualMachines"), h.join(".jdks"), h.join(".sdkman/candidates/java")]); } + roots.extend(EXTRA_ROOTS.lock().unwrap_or_else(|e| e.into_inner()).iter().cloned()); if cfg!(windows) { let bases = ["ProgramFiles", "ProgramW6432", "LOCALAPPDATA"].into_iter().filter_map(|v| { let dir = PathBuf::from(std::env::var_os(v)?); @@ -346,32 +364,51 @@ pub fn configure_jdtls( /// Where the JDK that runs jdtls is chosen, named as the settings dialog shows it. const SERVER_JDK_SETTING: &str = "Settings › Languages & Frameworks › Java › Language Server › JDK"; -fn missing_runtime_message(jdks: &[Jdk], min_java: u32) -> String { +/// `found JDK 8, 17` / `no JDK was found`. +fn found_jdks(jdks: &[Jdk]) -> String { let mut found: Vec = jdks.iter().map(|j| j.major).collect(); found.sort_unstable(); found.dedup(); - let found = if found.is_empty() { + if found.is_empty() { "no JDK was found".to_owned() } else { let list: Vec = found.iter().map(u32::to_string).collect(); format!("found JDK {}", list.join(", ")) - }; + } +} + +// The UI offers "Download JDK N" for messages saying "needs JDK N or newer". +fn missing_runtime_message(jdks: &[Jdk], min_java: u32) -> String { format!( - "jdtls needs JDK {min_java} or newer to run ({found}). Pick one in {SERVER_JDK_SETTING} (it restarts \ - automatically), or install a newer JDK and restart the server; the project can still target an older \ - Java version." + "jdtls needs JDK {min_java} or newer to run ({}). Download JDK {min_java}, or pick an installed one in \ + {SERVER_JDK_SETTING} (it restarts automatically); the project can still target an older Java version.", + found_jdks(jdks) ) } fn server_jdk_too_old_message(jdk: &Jdk, min_java: u32) -> String { format!( - "The language server JDK ({}, JDK {}) is too old: jdtls needs JDK {min_java} or newer. Choose another \ - one in {SERVER_JDK_SETTING}, or set it to Auto.", + "The language server JDK ({}, JDK {}) is too old: jdtls needs JDK {min_java} or newer. Download JDK \ + {min_java}, choose another one in {SERVER_JDK_SETTING}, or set it to Auto.", jdk.home.display(), jdk.major ) } +/// Why a module targeting Java `release` can't be built or run with the installed +/// JDKs (none is that new), or `None` when one is (or the release is unknown). +pub fn missing_jdk_message(jdks: &[Jdk], release: Option) -> Option { + let release = release?; + if jdks.iter().any(|j| j.major >= release) { + return None; + } + Some(format!( + "This project targets Java {release}: it needs JDK {release} or newer ({}). Download JDK {release}, or \ + choose a Project JDK in Settings › Languages & Frameworks › Java.", + found_jdks(jdks) + )) +} + /// The `java` launcher of a JDK home. pub fn java_executable(home: &Path) -> PathBuf { home.join("bin").join(crate::exe("java")) @@ -595,6 +632,62 @@ mod tests { configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &mixed, None, None, 25, &[], None); let err = cfg.launch_error.unwrap(); assert!(err.contains("JDK 25 or newer") && err.contains("found JDK 17, 21"), "{err}"); + assert!(err.contains("Download JDK 25"), "offers the download: {err}"); + } + + #[test] + fn issue_49_jdtls_needs_25_with_8_17_21_installed() { + let jdtls = || -> LanguageServerConfig { toml::from_str("name = \"jdtls\"\ncommand = \"jdtls\"").unwrap() }; + let installed = [ + Jdk { major: 21, home: PathBuf::from("/jdk21") }, + Jdk { major: 17, home: PathBuf::from("/jdk17") }, + Jdk { major: 8, home: PathBuf::from("/jdk8") }, + ]; + let mut cfg = jdtls(); + configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &installed, None, None, 25, &[], None); + let err = cfg.launch_error.unwrap(); + assert!(err.starts_with("jdtls needs JDK 25 or newer to run (found JDK 8, 17, 21)"), "{err}"); + assert!(err.contains("Download JDK 25"), "{err}"); + + // Once a JDK 25 has been downloaded, Auto runs jdtls on it. + let downloaded = + [installed.as_slice(), &[Jdk { major: 25, home: PathBuf::from("/tools/jdks/temurin-25") }]].concat(); + let mut cfg = jdtls(); + configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &downloaded, None, None, 25, &[], None); + assert_eq!(cfg.launch_error, None); + assert_eq!(cfg.env["JAVA_HOME"], Path::new("/tools/jdks/temurin-25").to_string_lossy()); + } + + #[test] + fn missing_jdk_for_the_project_release() { + let jdks = [Jdk { major: 17, home: PathBuf::from("/jdk17") }, Jdk { major: 8, home: PathBuf::from("/jdk8") }]; + assert_eq!(missing_jdk_message(&jdks, Some(17)), None); + assert_eq!(missing_jdk_message(&jdks, Some(11)), None); + assert_eq!(missing_jdk_message(&jdks, None), None); + let msg = missing_jdk_message(&jdks, Some(21)).unwrap(); + assert!(msg.contains("needs JDK 21 or newer (found JDK 8, 17)") && msg.contains("Download JDK 21"), "{msg}"); + assert!(missing_jdk_message(&[], Some(8)).unwrap().contains("no JDK was found")); + } + + #[test] + fn downloaded_jdks_are_found() { + let tmp = tempfile::tempdir().unwrap(); + let home = tmp.path().join("jdks/temurin-25.0.1"); + std::fs::create_dir_all(home.join("bin")).unwrap(); + std::fs::write(home.join("release"), "JAVA_VERSION=\"25.0.1\"\n").unwrap(); + std::fs::write(home.join("bin").join(crate::exe("java")), "").unwrap(); + // macOS bundles: /Contents/Home + let bundle = tmp.path().join("jdks/temurin-21.0.5/Contents/Home"); + std::fs::create_dir_all(bundle.join("bin")).unwrap(); + std::fs::write(bundle.join("release"), "JAVA_VERSION=\"21.0.5\"\n").unwrap(); + std::fs::write(bundle.join("bin").join(crate::exe("java")), "").unwrap(); + add_jdk_root(&tmp.path().join("jdks")); + let found = rescan_jdks(); + let home = dunce::canonicalize(&home).unwrap(); + let bundle = dunce::canonicalize(&bundle).unwrap(); + assert!(found.iter().any(|j| j.home == home && j.major == 25), "{found:?}"); + assert!(found.iter().any(|j| j.home == bundle && j.major == 21), "{found:?}"); + assert!(is_downloaded(&home) && !is_downloaded(Path::new("/elsewhere"))); } #[test] diff --git a/crates/ide-sdk/src/lib.rs b/crates/ide-sdk/src/lib.rs index 95651dd42..8660e9d52 100644 --- a/crates/ide-sdk/src/lib.rs +++ b/crates/ide-sdk/src/lib.rs @@ -19,7 +19,7 @@ use serde::{Deserialize, Serialize}; use specta::Type; use std::path::{Path, PathBuf}; -pub use discover::detect; +pub use discover::{detect, jdk_sdk}; pub use exec::search_path; /// What [`detect`] looks for. diff --git a/crates/ide-sdk/src/locate.rs b/crates/ide-sdk/src/locate.rs index d01005fcc..22717b59c 100644 --- a/crates/ide-sdk/src/locate.rs +++ b/crates/ide-sdk/src/locate.rs @@ -85,7 +85,11 @@ pub fn locate_tools_in(names: &[String], near: Option<&Path>, search: &std::ffi: .iter() .map(|name| { let path = valid_name(name) - .then(|| near.and_then(|n| local_bin(n, name)).or_else(|| on_path(name, search))) + .then(|| { + near.and_then(|n| local_bin(n, name)) + .or_else(|| on_path(name, search)) + .or_else(|| jdk_tool(name, &crate::java::installed_jdks())) + }) .flatten(); LocatedTool { name: name.clone(), path: path.map(Into::into), version: None } }) @@ -106,6 +110,16 @@ pub fn locate_tools_in(names: &[String], near: Option<&Path>, search: &std::ffi: }) } +/// `java` / `javac` / `jshell` of the newest installed JDK when none is on the +/// search path (JDKs installed without `PATH` changes, or downloaded by the IDE). +fn jdk_tool(name: &str, jdks: &[crate::java::Jdk]) -> Option { + if !["java", "javac", "jshell"].contains(&name) { + return None; + } + let jdk = jdks.iter().max_by_key(|j| j.major)?; + Some(jdk.home.join("bin").join(crate::exe(name))).filter(|p| p.is_file()) +} + /// Directory for binaries compiled from single source files (`rustc`, `clang`). pub fn compile_dir() -> PathBuf { std::env::temp_dir().join("openidea-run") @@ -124,6 +138,26 @@ mod tests { assert_eq!(parse_tool_version("no version here"), None); } + #[test] + fn java_falls_back_to_the_newest_installed_jdk() { + let tmp = tempfile::tempdir().unwrap(); + let jdk = |major: u32| { + let home = tmp.path().join(format!("jdk{major}")); + std::fs::create_dir_all(home.join("bin")).unwrap(); + for t in ["java", "javac"] { + std::fs::write(home.join("bin").join(crate::exe(t)), "").unwrap(); + } + crate::java::Jdk { major, home } + }; + let jdks = [jdk(17), jdk(25), jdk(21)]; + let newest = tmp.path().join("jdk25/bin"); + assert_eq!(jdk_tool("java", &jdks), Some(newest.join(crate::exe("java")))); + assert_eq!(jdk_tool("javac", &jdks), Some(newest.join(crate::exe("javac")))); + assert_eq!(jdk_tool("jshell", &jdks), None, "not in this JDK"); + assert_eq!(jdk_tool("node", &jdks), None); + assert_eq!(jdk_tool("java", &[]), None); + } + #[test] fn rejects_paths_and_shell_syntax() { assert!(valid_name("ts-node")); diff --git a/crates/ide-tools/src/jdk.rs b/crates/ide-tools/src/jdk.rs new file mode 100644 index 000000000..d93212f6d --- /dev/null +++ b/crates/ide-tools/src/jdk.rs @@ -0,0 +1,477 @@ +//! JDK downloads (IDEA's Download JDK): Eclipse Temurin builds from the +//! Adoptium API, unpacked into `/jdks/-` (or a chosen +//! location) where JDK discovery finds them. Parsing is pure so it is testable +//! without network. + +use crate::platform::{Arch, Os, Platform}; +use crate::process::{Emit, Http}; +use crate::sources::Download; +use crate::{InstallEvent, InstallGuard, Tools, archive, clear_quarantine, fetch_verified}; +use ide_core::error::ErrorKind; +use ide_core::{CancellationToken, IdeError, IdeResult}; +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::path::{Path, PathBuf}; + +const ADOPTIUM_API: &str = "https://api.adoptium.net/v3"; + +/// A JDK distribution the IDE can download. +#[derive(Clone, Debug, PartialEq, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct JdkVendor { + pub id: String, + pub name: String, +} + +pub const TEMURIN: &str = "temurin"; + +pub fn vendors() -> Vec { + vec![JdkVendor { id: TEMURIN.into(), name: "Eclipse Temurin".into() }] +} + +/// Java versions a vendor publishes. +#[derive(Clone, Debug, PartialEq, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct JdkReleases { + /// Feature versions, newest first. + pub versions: Vec, + /// The long-term-support ones among them. + pub lts: Vec, + /// The version offered first (the newest LTS). + pub recommended: u32, +} + +/// A JDK the IDE downloaded. +#[derive(Clone, Debug, PartialEq, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct DownloadedJdk { + pub home: ide_core::IpcPath, + /// Full version, e.g. `25.0.1`. + pub version: String, + pub major: u32, +} + +/// Where downloaded JDKs go by default. +pub fn jdks_dir(app_data: &Path) -> PathBuf { + crate::tools_dir(app_data).join("jdks") +} + +fn proto(msg: impl Into) -> IdeError { + IdeError::new(ErrorKind::Protocol, msg) +} + +fn vendor_or_err(vendor: &str) -> IdeResult<()> { + if vendor == TEMURIN { Ok(()) } else { Err(IdeError::invalid(format!("unknown JDK vendor `{vendor}`"))) } +} + +pub fn adoptium_releases_url() -> String { + format!("{ADOPTIUM_API}/info/available_releases") +} + +#[derive(Deserialize)] +struct AvailableReleases { + available_releases: Vec, + #[serde(default)] + available_lts_releases: Vec, + #[serde(default)] + most_recent_lts: Option, +} + +pub fn parse_releases(json: &str) -> IdeResult { + let r: AvailableReleases = + serde_json::from_str(json).map_err(|e| proto(format!("unexpected Adoptium response: {e}")))?; + let mut versions = r.available_releases; + versions.sort_unstable_by(|a, b| b.cmp(a)); + versions.dedup(); + let recommended = r + .most_recent_lts + .or_else(|| r.available_lts_releases.iter().copied().max()) + .or_else(|| versions.first().copied()) + .ok_or_else(|| proto("Adoptium lists no Java versions"))?; + Ok(JdkReleases { versions, lts: r.available_lts_releases, recommended }) +} + +fn adoptium_os(os: Os) -> &'static str { + match os { + Os::Mac => "mac", + Os::Linux => "linux", + Os::Windows => "windows", + } +} + +fn adoptium_arch(arch: Arch) -> &'static str { + match arch { + Arch::X64 => "x64", + Arch::Arm64 => "aarch64", + } +} + +/// The latest Temurin JDK build of Java `feature` for `platform`. +pub fn adoptium_assets_url(feature: u32, platform: Platform) -> String { + format!( + "{ADOPTIUM_API}/assets/latest/{feature}/hotspot?architecture={}&image_type=jdk&os={}&vendor=eclipse", + adoptium_arch(platform.arch), + adoptium_os(platform.os), + ) +} + +#[derive(Deserialize)] +struct Asset { + binary: Binary, + version: AssetVersion, +} + +#[derive(Deserialize)] +struct Binary { + #[serde(default)] + image_type: Option, + #[serde(default)] + heap_size: Option, + #[serde(default)] + os: Option, + package: Package, +} + +#[derive(Deserialize)] +struct Package { + name: String, + link: String, + #[serde(default)] + checksum: Option, + #[serde(default)] + size: Option, +} + +#[derive(Deserialize)] +struct AssetVersion { + major: u32, + #[serde(default)] + minor: u32, + #[serde(default)] + security: u32, + #[serde(default)] + openjdk_version: Option, +} + +impl AssetVersion { + /// `25.0.1` (`openjdk_version` without its build; `1.8.0_472` style for Java 8). + fn display(&self) -> String { + let plain = || format!("{}.{}.{}", self.major, self.minor, self.security); + match &self.openjdk_version { + Some(v) => { + let v = v.split(['+', '-']).next().unwrap_or(v); + if v.is_empty() { plain() } else { v.to_owned() } + } + None => plain(), + } + } +} + +/// The JDK archive (`.tar.gz` on macOS / Linux, `.zip` on Windows) in an Adoptium +/// `assets/latest` response; `None` when there is no build for the platform. +pub fn adoptium_pick(json: &str, platform: Platform) -> IdeResult> { + let assets: Vec = + serde_json::from_str(json).map_err(|e| proto(format!("unexpected Adoptium response: {e}")))?; + let ext = if platform.os == Os::Windows { ".zip" } else { ".tar.gz" }; + let os = adoptium_os(platform.os); + let found = assets.into_iter().find(|a| { + a.binary.image_type.as_deref().is_none_or(|t| t == "jdk") + && a.binary.heap_size.as_deref().is_none_or(|h| h == "normal") + && a.binary.os.as_deref().is_none_or(|o| o == os) + && a.binary.package.name.ends_with(ext) + }); + Ok(found.map(|a| { + let sha256 = a.binary.package.checksum.map(|c| c.trim().to_ascii_lowercase()).filter(|c| c.len() == 64); + ( + Download { + version: a.version.display(), + name: a.binary.package.name, + url: a.binary.package.link, + size: a.binary.package.size, + sha256, + checksum_url: None, + }, + a.version.major, + ) + })) +} + +/// Folder name of a downloaded JDK, as IDEA names them: `temurin-25.0.1`. +pub fn jdk_dir_name(vendor: &str, version: &str) -> String { + let safe: String = + version.chars().map(|c| if c.is_ascii_alphanumeric() || matches!(c, '.' | '_') { c } else { '-' }).collect(); + format!("{vendor}-{safe}") +} + +fn is_jdk_home(dir: &Path, platform: Platform) -> bool { + dir.join("release").is_file() && dir.join("bin").join(platform.exe("java")).is_file() +} + +/// The JDK home inside an unpacked JDK: `dir` itself, `dir/Contents/Home` +/// (a macOS bundle), or either of those below one top-level folder +/// (`jdk-25.0.1+8/`, `jdk-25.0.1+8/Contents/Home`). +pub fn find_jdk_home(dir: &Path, platform: Platform) -> Option { + let candidates = |d: &Path| [d.to_path_buf(), d.join("Contents").join("Home")]; + if let Some(home) = candidates(dir).into_iter().find(|h| is_jdk_home(h, platform)) { + return Some(home); + } + let mut subdirs: Vec = std::fs::read_dir(dir) + .ok()? + .flatten() + .filter(|e| e.file_type().is_ok_and(|t| t.is_dir())) + .map(|e| e.path()) + .collect(); + subdirs.sort(); + subdirs.iter().flat_map(|d| candidates(d)).find(|h| is_jdk_home(h, platform)) +} + +/// The directory to move into place: the archive's single top-level folder +/// holding the JDK, else everything that was unpacked. +fn unpacked_root(pkg: &Path, home: &Path) -> PathBuf { + match home.strip_prefix(pkg).ok().and_then(|rel| rel.components().next()) { + Some(first) if first.as_os_str() != "Contents" => pkg.join(first), + _ => pkg.to_path_buf(), + } +} + +impl Tools { + fn http(&self) -> IdeResult { + Http::new(&self.system_path) + } + + /// Java versions `vendor` publishes. + pub fn jdk_releases(&self, vendor: &str, cancel: &CancellationToken) -> IdeResult { + vendor_or_err(vendor)?; + parse_releases(&self.http()?.get_text(&adoptium_releases_url(), cancel)?) + } + + /// Downloads the latest `vendor` JDK of Java `feature` into + /// `/-` (`location` defaults to the tools' `jdks` + /// folder), verifying its SHA-256. A JDK already there is reused. + pub fn download_jdk( + &self, + vendor: &str, + feature: u32, + location: Option<&Path>, + cancel: &CancellationToken, + emit: Emit, + ) -> IdeResult { + vendor_or_err(vendor)?; + let _guard = InstallGuard::acquire(&format!("jdk-{vendor}-{feature}"))?; + let http = self.http()?; + let location = location.map(Path::to_path_buf).unwrap_or_else(|| self.root.join("jdks")); + emit(InstallEvent::Line { text: format!("Resolving the latest Eclipse Temurin {feature}…") }); + let mut picked = + adoptium_pick(&http.get_text(&adoptium_assets_url(feature, self.platform), cancel)?, self.platform)?; + if picked.is_none() && self.platform.os == Os::Mac && self.platform.arch == Arch::Arm64 { + // Java 8 has no Apple Silicon build: the Intel one runs under Rosetta. + let intel = Platform { os: Os::Mac, arch: Arch::X64 }; + picked = adoptium_pick(&http.get_text(&adoptium_assets_url(feature, intel), cancel)?, intel)?; + } + let (dl, major) = picked.ok_or_else(|| { + IdeError::unavailable(format!( + "Eclipse Temurin {feature} has no build for {:?} {:?}", + self.platform.os, self.platform.arch + )) + })?; + let target = location.join(jdk_dir_name(vendor, &dl.version)); + let done = |home: PathBuf| DownloadedJdk { home: home.into(), version: dl.version.clone(), major }; + if let Some(home) = find_jdk_home(&target, self.platform) { + emit(InstallEvent::Line { text: format!("{} is already installed", target.display()) }); + return Ok(done(home)); + } + emit(InstallEvent::Line { text: format!("Downloading {} ({})", dl.name, dl.url) }); + std::fs::create_dir_all(&location)?; + // Next to the target, so moving it into place never crosses file systems. + let staging = location.join(format!(".staging-{}", jdk_dir_name(vendor, &dl.version))); + let _ = std::fs::remove_dir_all(&staging); + std::fs::create_dir_all(&staging)?; + let result = (|| { + let archive = fetch_verified(&http, &dl, &staging, cancel, emit)?; + emit(InstallEvent::Line { text: "Extracting…".into() }); + let pkg = staging.join("pkg"); + archive::extract(&archive, archive::ArchiveKind::from_name(&dl.name), &pkg, "java")?; + let home = find_jdk_home(&pkg, self.platform) + .ok_or_else(|| IdeError::new(ErrorKind::Io, format!("no JDK found in {}", dl.name)))?; + if target.exists() { + std::fs::remove_dir_all(&target)?; + } + std::fs::rename(unpacked_root(&pkg, &home), &target)?; + clear_quarantine(&target); + find_jdk_home(&target, self.platform) + .ok_or_else(|| IdeError::new(ErrorKind::Io, format!("no JDK found in {}", target.display()))) + })(); + let _ = std::fs::remove_dir_all(&staging); + let home = result?; + emit(InstallEvent::Line { text: format!("Eclipse Temurin {} installed in {}", dl.version, home.display()) }); + Ok(done(home)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const MAC_ARM: Platform = Platform { os: Os::Mac, arch: Arch::Arm64 }; + const LINUX_X64: Platform = Platform { os: Os::Linux, arch: Arch::X64 }; + const WIN_X64: Platform = Platform { os: Os::Windows, arch: Arch::X64 }; + + #[test] + fn releases() { + let json = r#"{"available_lts_releases":[8,11,17,21,25],"available_releases":[8,11,16,17,21,24,25,26], + "most_recent_feature_release":26,"most_recent_lts":25,"tip_version":27}"#; + let r = parse_releases(json).unwrap(); + assert_eq!(r.versions, [26, 25, 24, 21, 17, 16, 11, 8]); + assert_eq!(r.lts, [8, 11, 17, 21, 25]); + assert_eq!(r.recommended, 25); + assert!(parse_releases("{}").is_err()); + } + + #[test] + fn asset_urls() { + assert_eq!( + adoptium_assets_url(25, MAC_ARM), + "https://api.adoptium.net/v3/assets/latest/25/hotspot?architecture=aarch64&image_type=jdk&os=mac&vendor=eclipse" + ); + assert_eq!( + adoptium_assets_url(21, WIN_X64), + "https://api.adoptium.net/v3/assets/latest/21/hotspot?architecture=x64&image_type=jdk&os=windows&vendor=eclipse" + ); + assert!(adoptium_assets_url(17, LINUX_X64).contains("os=linux")); + } + + fn asset(name: &str, os: &str, heap: &str, checksum: &str) -> String { + format!( + r#"{{"binary":{{"architecture":"x64","heap_size":"{heap}","image_type":"jdk","os":"{os}", + "installer":{{"name":"x.msi","link":"https://e/x.msi","checksum":"00"}}, + "package":{{"name":"{name}","link":"https://e/{name}","checksum":"{checksum}","size":1234}}}}, + "release_name":"jdk-25.0.1+8","vendor":"eclipse", + "version":{{"major":25,"minor":0,"security":1,"build":8,"openjdk_version":"25.0.1+8-LTS"}}}}"# + ) + } + + #[test] + fn picks_the_platform_archive() { + let sum = "36DC14D797AAFF6D89AAB302CD053FDBA6207DFDCF772052FA7B023A94B0E857"; + let json = format!( + "[{},{},{}]", + asset("OpenJDK25U-jdk_x64_windows_hotspot_25.0.1_8.zip", "windows", "large", sum), + asset("OpenJDK25U-jdk_x64_windows_hotspot_25.0.1_8.zip", "windows", "normal", sum), + asset("OpenJDK25U-jdk_x64_linux_hotspot_25.0.1_8.tar.gz", "linux", "normal", sum), + ); + let (dl, major) = adoptium_pick(&json, WIN_X64).unwrap().unwrap(); + assert_eq!(dl.name, "OpenJDK25U-jdk_x64_windows_hotspot_25.0.1_8.zip"); + assert_eq!(dl.url, "https://e/OpenJDK25U-jdk_x64_windows_hotspot_25.0.1_8.zip"); + assert_eq!(dl.sha256.as_deref(), Some(sum.to_ascii_lowercase().as_str())); + assert_eq!((dl.version.as_str(), major, dl.size), ("25.0.1", 25, Some(1234))); + let (dl, _) = adoptium_pick(&json, LINUX_X64).unwrap().unwrap(); + assert!(dl.name.ends_with(".tar.gz")); + // No build for the platform (Java 8 on Apple Silicon): an empty list. + assert_eq!(adoptium_pick("[]", MAC_ARM).unwrap(), None); + assert_eq!(adoptium_pick(&json, MAC_ARM).unwrap(), None); + assert!(adoptium_pick("{\"errorMessage\":\"x\"}", MAC_ARM).is_err()); + } + + #[test] + fn java_8_versions_and_dir_names() { + let v = AssetVersion { major: 8, minor: 0, security: 472, openjdk_version: Some("1.8.0_472-b08".into()) }; + assert_eq!(v.display(), "1.8.0_472"); + let v = AssetVersion { major: 21, minor: 0, security: 9, openjdk_version: None }; + assert_eq!(v.display(), "21.0.9"); + assert_eq!(jdk_dir_name(TEMURIN, "25.0.1"), "temurin-25.0.1"); + assert_eq!(jdk_dir_name(TEMURIN, "1.8.0_472"), "temurin-1.8.0_472"); + } + + fn fake_jdk(home: &Path, platform: Platform) { + std::fs::create_dir_all(home.join("bin")).unwrap(); + std::fs::write(home.join("release"), "JAVA_VERSION=\"25.0.1\"\n").unwrap(); + std::fs::write(home.join("bin").join(platform.exe("java")), "").unwrap(); + } + + #[test] + fn finds_the_home_of_each_archive_layout() { + let tmp = tempfile::tempdir().unwrap(); + // macOS: jdk-25.0.1+8/Contents/Home + let mac = tmp.path().join("mac"); + fake_jdk(&mac.join("jdk-25.0.1+8/Contents/Home"), MAC_ARM); + std::fs::create_dir_all(mac.join("jdk-25.0.1+8/Contents/MacOS")).unwrap(); + let home = find_jdk_home(&mac, MAC_ARM).unwrap(); + assert_eq!(home, mac.join("jdk-25.0.1+8/Contents/Home")); + assert_eq!(unpacked_root(&mac, &home), mac.join("jdk-25.0.1+8")); + // Once moved into place, the bundle itself. + assert_eq!(find_jdk_home(&mac.join("jdk-25.0.1+8"), MAC_ARM).unwrap(), mac.join("jdk-25.0.1+8/Contents/Home")); + // Linux tar.gz / Windows zip: jdk-25.0.1+8/bin/java[.exe] + for platform in [LINUX_X64, WIN_X64] { + let dir = tmp.path().join(format!("{:?}", platform.os)); + fake_jdk(&dir.join("jdk-25.0.1+8"), platform); + let home = find_jdk_home(&dir, platform).unwrap(); + assert_eq!(home, dir.join("jdk-25.0.1+8")); + assert_eq!(unpacked_root(&dir, &home), dir.join("jdk-25.0.1+8")); + } + // Without a top-level folder, everything unpacked is the JDK. + let flat = tmp.path().join("flat"); + fake_jdk(&flat, LINUX_X64); + assert_eq!(find_jdk_home(&flat, LINUX_X64).unwrap(), flat); + assert_eq!(unpacked_root(&flat, &flat), flat); + let bundle = tmp.path().join("bundle"); + fake_jdk(&bundle.join("Contents/Home"), MAC_ARM); + assert_eq!(unpacked_root(&bundle, &bundle.join("Contents/Home")), bundle); + // A Windows layout isn't a JDK for Linux (no `bin/java`), nor is a folder without `release`. + assert_eq!(find_jdk_home(&tmp.path().join("Windows"), LINUX_X64), None); + std::fs::create_dir_all(tmp.path().join("norelease/bin")).unwrap(); + std::fs::write(tmp.path().join("norelease/bin/java"), "").unwrap(); + assert_eq!(find_jdk_home(&tmp.path().join("norelease"), LINUX_X64), None); + } + + #[test] + fn unknown_vendors_are_refused() { + let tools = Tools::with_paths(PathBuf::from("/nonexistent"), Default::default(), LINUX_X64); + let err = tools.download_jdk("acme", 25, None, &CancellationToken::new(), &|_| {}).unwrap_err(); + assert_eq!(err.kind, ErrorKind::InvalidInput, "{err:?}"); + } + + /// Downloads a real JDK from Adoptium: `OPENIDEA_LIVE_JDK=25 cargo nextest run -p ide-tools live_download`; + /// `OPENIDEA_LIVE_JDK_OS=windows|linux` fetches and unpacks another platform's archive (not run). + #[test] + fn live_download() { + let Some(feature) = std::env::var("OPENIDEA_LIVE_JDK").ok().and_then(|v| v.parse::().ok()) else { + return; + }; + let tmp = tempfile::tempdir().unwrap(); + let other = match std::env::var("OPENIDEA_LIVE_JDK_OS").as_deref() { + Ok("windows") => Some(WIN_X64), + Ok("linux") => Some(LINUX_X64), + _ => None, + }; + if let Some(platform) = other { + let tools = Tools::with_paths(crate::tools_dir(tmp.path()), crate::default_search_path(), platform); + let jdk = tools.download_jdk(TEMURIN, feature, None, &CancellationToken::new(), &|_| {}).unwrap(); + let home = jdk.home.into_path_buf(); + eprintln!("{}", home.display()); + assert!(home.join("bin").join(platform.exe("java")).is_file()); + assert_eq!(home.parent(), Some(jdks_dir(tmp.path()).as_path()), "no Contents/Home outside macOS"); + return; + } + let tools = Tools::new(tmp.path()); + let releases = tools.jdk_releases(TEMURIN, &CancellationToken::new()).unwrap(); + assert!(releases.versions.contains(&feature), "{releases:?}"); + let emit = |e: InstallEvent| { + if let InstallEvent::Line { text } = e { + eprintln!("{text}"); + } + }; + let jdk = tools.download_jdk(TEMURIN, feature, None, &CancellationToken::new(), &emit).unwrap(); + let home = jdk.home.clone().into_path_buf(); + assert!(home.starts_with(jdks_dir(tmp.path())), "{}", home.display()); + let out = std::process::Command::new(home.join("bin").join(Platform::current().exe("java"))) + .arg("-version") + .output() + .unwrap(); + let text = String::from_utf8_lossy(&out.stderr); + eprintln!("{text}"); + assert!(out.status.success() && text.contains("Temurin"), "{text}"); + // Downloading again reuses it. + let again = tools.download_jdk(TEMURIN, feature, None, &CancellationToken::new(), &|_| {}).unwrap(); + assert_eq!(again.home, jdk.home); + } +} diff --git a/crates/ide-tools/src/lib.rs b/crates/ide-tools/src/lib.rs index f11db8bf1..3e00a6cd6 100644 --- a/crates/ide-tools/src/lib.rs +++ b/crates/ide-tools/src/lib.rs @@ -10,6 +10,7 @@ //! [`tools_bin_dirs`] on the process `PATH` at startup. pub mod archive; +pub mod jdk; pub mod platform; mod process; pub mod registry; @@ -948,33 +949,7 @@ impl Tools { cancel: &CancellationToken, emit: Emit, ) -> IdeResult<()> { - let archive = staging.join(&dl.name); - http.download(&dl.url, &archive, dl.size, cancel, emit)?; - let expected = match (&dl.sha256, &dl.checksum_url) { - (Some(h), _) => Some(h.clone()), - (None, Some(url)) => match http.get_text(url, cancel) { - Ok(text) => sources::parse_checksum(&text, &dl.name), - Err(e) if e.kind == ErrorKind::Cancelled => return Err(e), - Err(_) => None, - }, - (None, None) => None, - }; - match expected { - Some(expected) => { - let actual = sources::sha256_file(&archive)?; - if actual != expected { - return Err(IdeError::new( - ErrorKind::Io, - format!("checksum mismatch for {}: expected {expected}, got {actual}", dl.name), - )); - } - emit(InstallEvent::Line { text: "Checksum verified (SHA-256)".into() }); - } - None => emit(InstallEvent::Line { text: "No checksum published; skipping verification".into() }), - } - if cancel.is_cancelled() { - return Err(IdeError::cancelled()); - } + let archive = fetch_verified(http, dl, staging, cancel, emit)?; emit(InstallEvent::Line { text: "Extracting…".into() }); let pkg = staging.join("pkg"); let kind = archive::ArchiveKind::from_name(&dl.name); @@ -1018,11 +993,8 @@ impl Tools { archive::link_executable(&bin, main, &target)?; } } - #[cfg(target_os = "macos")] - { - // Downloads made by the IDE itself are not quarantined, but archives may carry the flag. - let _ = Command::new("xattr").args(["-dr", "com.apple.quarantine"]).arg(&final_dir).output(); - } + // Downloads made by the IDE itself are not quarantined, but archives may carry the flag. + clear_quarantine(&final_dir); self.write_manifest(spec.name, &Manifest { version: Some(dl.version.clone()), source: Some(dl.url.clone()) }) } @@ -1091,5 +1063,54 @@ impl Tools { } } +/// Downloads `dl` into `staging` and checks its SHA-256 when one is published; +/// returns the downloaded file. +fn fetch_verified( + http: &Http, + dl: &sources::Download, + staging: &Path, + cancel: &CancellationToken, + emit: Emit, +) -> IdeResult { + let archive = staging.join(&dl.name); + http.download(&dl.url, &archive, dl.size, cancel, emit)?; + let expected = match (&dl.sha256, &dl.checksum_url) { + (Some(h), _) => Some(h.clone()), + (None, Some(url)) => match http.get_text(url, cancel) { + Ok(text) => sources::parse_checksum(&text, &dl.name), + Err(e) if e.kind == ErrorKind::Cancelled => return Err(e), + Err(_) => None, + }, + (None, None) => None, + }; + match expected { + Some(expected) => { + let actual = sources::sha256_file(&archive)?; + if actual != expected { + return Err(IdeError::new( + ErrorKind::Io, + format!("checksum mismatch for {}: expected {expected}, got {actual}", dl.name), + )); + } + emit(InstallEvent::Line { text: "Checksum verified (SHA-256)".into() }); + } + None => emit(InstallEvent::Line { text: "No checksum published; skipping verification".into() }), + } + if cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } + Ok(archive) +} + +/// Removes the quarantine flag archives may carry, so macOS runs what was unpacked. +fn clear_quarantine(dir: &Path) { + #[cfg(target_os = "macos")] + { + let _ = Command::new("xattr").args(["-dr", "com.apple.quarantine"]).arg(dir).output(); + } + #[cfg(not(target_os = "macos"))] + let _ = dir; +} + #[cfg(test)] mod tests; diff --git a/src-tauri/src/commands/build.rs b/src-tauri/src/commands/build.rs index 4ec6b4206..05bb1336a 100644 --- a/src-tauri/src/commands/build.rs +++ b/src-tauri/src/commands/build.rs @@ -42,6 +42,28 @@ pub(crate) fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel if let Some(g) = &mut model.gradle { g.env = java_env(jdk.as_ref(), g.java_version); } + // A failed sync of a project no installed JDK can build says so first (with a Download JDK action). + let missing = |release: Option| match jdk { + Some(_) => None, + None => ide_sdk::java::missing_jdk_message(&ide_sdk::java::installed_jdks(), release), + }; + let explain = |error: &mut Option, hint: Option| { + if let (Some(e), Some(hint)) = (error.as_mut(), hint) { + *e = format!("{hint}\n{e}"); + } + }; + if let Some(m) = &mut model.maven + && m.sync_error.is_some() + { + let hint = m.projects.iter().find_map(|p| missing(p.java_version)); + explain(&mut m.sync_error, hint); + } + if let Some(g) = &mut model.gradle + && g.sync_error.is_some() + { + let hint = missing(g.java_version); + explain(&mut g.sync_error, hint); + } model } diff --git a/src-tauri/src/commands/java.rs b/src-tauri/src/commands/java.rs index 76a56de12..c75dd7b39 100644 --- a/src-tauri/src/commands/java.rs +++ b/src-tauri/src/commands/java.rs @@ -48,6 +48,7 @@ pub async fn java_resolve_launch( let java = project_jdk.map(|j| ide_sdk::java::java_executable(&j.home)).or(from_jdtls).or_else(|| { let release = ide_sdk::java::project_java_version(&module); let jdks = ide_sdk::java::installed_jdks(); + info.hints.extend(ide_sdk::java::missing_jdk_message(&jdks, release)); ide_sdk::java::pick_jdk(&jdks, release).map(|j| ide_sdk::java::java_executable(&j.home)) }); info.java_exec = Some(java.map(|p| p.to_string_lossy().into_owned()).unwrap_or_else(|| "java".to_owned())); diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index c30394997..7dec66ab6 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -253,6 +253,10 @@ pub fn builder() -> tauri_specta::Builder { tools::tools_list, tools::tools_install, tools::tools_uninstall, + tools::jdk_download_vendors, + tools::jdk_download_releases, + tools::jdk_download_location, + tools::jdk_download, sdk::sdk_detect, sdk::sdk_python_effective, sdk::sdk_project_libraries, diff --git a/src-tauri/src/commands/tools.rs b/src-tauri/src/commands/tools.rs index 729f6324e..184f7ec71 100644 --- a/src-tauri/src/commands/tools.rs +++ b/src-tauri/src/commands/tools.rs @@ -1,8 +1,11 @@ -//! Language server / debug adapter installer (`ide-tools`). +//! Language server / debug adapter installer and JDK downloads (`ide-tools`). use crate::state::AppState; use ide_core::{IdeError, IdeResult, RequestId}; +use ide_sdk::Sdk; +use ide_tools::jdk::{JdkReleases, JdkVendor}; use ide_tools::{InstallEvent, ToolInfo, Tools}; +use std::path::PathBuf; use tauri::ipc::Channel; use tauri::{AppHandle, Manager, State, Window}; @@ -81,6 +84,81 @@ pub async fn tools_uninstall( Ok(info) } +/// JDK distributions Download JDK offers. +#[tauri::command] +#[specta::specta] +pub fn jdk_download_vendors() -> Vec { + ide_tools::jdk::vendors() +} + +/// Java versions `vendor` publishes; cancellable with `cancel_request(request)`. +#[tauri::command] +#[specta::specta] +pub async fn jdk_download_releases( + app: AppHandle, + window: Window, + state: State<'_, AppState>, + vendor: String, + request: RequestId, +) -> IdeResult { + let tools = tools(&app, &state, &window)?; + let cancel = state.begin_request(request); + let result = + tauri::async_runtime::spawn_blocking(move || tools.jdk_releases(&vendor, &cancel)).await.map_err(join_err); + state.end_request(request); + result? +} + +/// Where Download JDK puts JDKs unless told otherwise. +#[tauri::command] +#[specta::specta] +pub fn jdk_download_location(app: AppHandle) -> IdeResult { + let data = app.path().app_data_dir().map_err(|e| IdeError::internal(e.to_string()))?; + Ok(ide_tools::jdk::jdks_dir(&data).into()) +} + +/// Downloads the latest `vendor` JDK of Java `version` into `location` (default: +/// [`jdk_download_location`]), streaming progress through `on_event`; cancellable +/// with `cancel_request(request)`. The JDK is then detected like an installed one, +/// and language servers waiting for a JDK (jdtls) start on it. +#[tauri::command] +#[specta::specta] +#[allow(clippy::too_many_arguments)] +pub async fn jdk_download( + app: AppHandle, + window: Window, + state: State<'_, AppState>, + vendor: String, + version: u32, + location: Option, + request: RequestId, + on_event: Channel, +) -> IdeResult { + let tools = tools(&app, &state, &window)?; + let location = location.filter(|p| !p.as_os_str().is_empty()); + let cancel = state.begin_request(request); + let result = tauri::async_runtime::spawn_blocking(move || { + let jdk = tools.download_jdk(&vendor, version, location.as_deref(), &cancel, &|e| { + let _ = on_event.send(e); + })?; + let home = jdk.home.into_path_buf(); + // A chosen location is scanned from now on too (the default one is from startup). + if let Some(location) = &location { + ide_sdk::java::add_jdk_root(location); + } + ide_sdk::java::rescan_jdks(); + let jdk = ide_sdk::java::jdk_at(&home) + .ok_or_else(|| IdeError::internal(format!("{} is not a JDK home", home.display())))?; + IdeResult::Ok(ide_sdk::jdk_sdk(&jdk)) + }) + .await + .map_err(join_err); + state.end_request(request); + let sdk = result??; + reconfigure_servers(&state); + Ok(sdk) +} + fn reconfigure_servers(state: &AppState) { for project in state.projects() { let lsp = project.lsp.clone(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 63441fef4..463721f43 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -41,6 +41,7 @@ pub fn run() { if let Some(data) = dirs::data_dir() { ide_tools::extend_process_path(&data.join(&context.config().identifier)); ide_dap::adapter::set_tools_root(ide_tools::tools_dir(&data.join(&context.config().identifier))); + ide_sdk::java::add_jdk_root(&ide_tools::jdk::jdks_dir(&data.join(&context.config().identifier))); } if let Some(refresh) = refresh_shell_path { refresh(); diff --git a/src/bindings.ts b/src/bindings.ts index c76ff5c57..a4eebe9f7 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -497,6 +497,19 @@ export const commands = { toolsInstall: (name: string, request: RequestId, onEvent: Channel) => typedError(__TAURI_INVOKE("tools_install", { name, request, onEvent })), /** Removes a tool the IDE installed. */ toolsUninstall: (name: string) => typedError(__TAURI_INVOKE("tools_uninstall", { name })), + /** JDK distributions Download JDK offers. */ + jdkDownloadVendors: () => __TAURI_INVOKE("jdk_download_vendors"), + /** Java versions `vendor` publishes; cancellable with `cancel_request(request)`. */ + jdkDownloadReleases: (vendor: string, request: RequestId) => typedError(__TAURI_INVOKE("jdk_download_releases", { vendor, request })), + /** Where Download JDK puts JDKs unless told otherwise. */ + jdkDownloadLocation: () => typedError(__TAURI_INVOKE("jdk_download_location")), + /** + * Downloads the latest `vendor` JDK of Java `version` into `location` (default: + * [`jdk_download_location`]), streaming progress through `on_event`; cancellable + * with `cancel_request(request)`. The JDK is then detected like an installed one, + * and language servers waiting for a JDK (jdtls) start on it. + */ + jdkDownload: (vendor: string, version: number, location: string | null, request: RequestId, onEvent: Channel) => typedError(__TAURI_INVOKE("jdk_download", { vendor, version, location, request, onEvent })), /** Installed interpreters / toolchains of `kind`; project-local ones first. */ sdkDetect: (kind: SdkKind) => typedError(__TAURI_INVOKE("sdk_detect", { kind })), /** The interpreter the project uses: the configured one, else its virtualenv. */ @@ -2888,6 +2901,22 @@ export type JavaSettings = { maven?: MavenSettings, }; +/** Java versions a vendor publishes. */ +export type JdkReleases = { + /** Feature versions, newest first. */ + versions: number[], + /** The long-term-support ones among them. */ + lts: number[], + /** The version offered first (the newest LTS). */ + recommended: number, +}; + +/** A JDK distribution the IDE can download. */ +export type JdkVendor = { + id: string, + name: string, +}; + /** Editor › Code Style › › Tabs and Indents (unset fields inherit). */ export type LanguageCodeStyle = LanguageCodeStyle_Serialize | LanguageCodeStyle_Deserialize; diff --git a/src/features/build-tools/BuildToolView.tsx b/src/features/build-tools/BuildToolView.tsx index 7026d69f8..f4b64d6d4 100644 --- a/src/features/build-tools/BuildToolView.tsx +++ b/src/features/build-tools/BuildToolView.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState, type MouseEvent } from "react"; import clsx from "clsx"; import type { BuildTool, GradleModel, MavenModel, RunConfig } from "@/platform/ipc"; +import { downloadJdkActions } from "@/platform/jdk"; import { useProject } from "@/platform/project"; import { editorService } from "@/workbench/contributions"; import { @@ -191,13 +192,20 @@ function Banner({ tool }: { tool: BuildTool }) { ); - if (info.syncError) + if (info.syncError) { + const [jdkFix] = downloadJdkActions(info.syncError); return (
{info.syncError.split("\n")[0]} + {jdkFix && ( + + )}
); + } return null; } diff --git a/src/features/build-tools/store.ts b/src/features/build-tools/store.ts index 4125f0921..1d12067bd 100644 --- a/src/features/build-tools/store.ts +++ b/src/features/build-tools/store.ts @@ -20,6 +20,7 @@ import { type RunConfig, type SyncProgress, } from "@/platform/ipc"; +import { downloadJdkActions } from "@/platform/jdk"; import { useProject } from "@/platform/project"; import { notifications } from "@/workbench/contributions"; import { removeTask, upsertTask } from "@/workbench/tasks"; @@ -207,6 +208,7 @@ export async function syncTool(tool: BuildTool): Promise { title: tool === "maven" ? "Maven sync finished with errors" : "Gradle sync failed", body: error, key: `build-sync-${tool}`, + actions: downloadJdkActions(error), }); } catch (e) { if (!isCancelled(e)) diff --git a/src/features/code-insight/ServerStatus.test.tsx b/src/features/code-insight/ServerStatus.test.tsx new file mode 100644 index 000000000..fbb023603 --- /dev/null +++ b/src/features/code-insight/ServerStatus.test.tsx @@ -0,0 +1,34 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { registerAction } from "@/platform/actions"; +import { ServerStatus } from "./ServerStatus"; +import { useServers, type ServerEntry } from "./servers"; + +const ISSUE_49 = + "jdtls needs JDK 25 or newer to run (found JDK 8, 17, 21). Download JDK 25, or pick an installed one in Settings › Languages & Frameworks › Java › Language Server › JDK (it restarts automatically); the project can still target an older Java version."; + +const server = (status: ServerEntry["status"], message: string | null): ServerEntry => + ({ id: 1, name: "jdtls", status, message, languages: ["Java"], root: null }) as unknown as ServerEntry; + +afterEach(() => useServers.setState({ servers: new Map() })); + +describe("Language server status popup", () => { + it("offers Download JDK N when jdtls can't start for want of a JDK", () => { + const run = vi.fn(); + const off = registerAction({ id: "java.downloadJdk", title: "Download JDK…", run: ({ arg }) => run(arg) }); + useServers.setState({ servers: new Map([[1 as never, server("misconfigured", ISSUE_49)]]) }); + render(); + fireEvent.click(screen.getByRole("button", { name: /Language servers/ })); + fireEvent.click(screen.getByRole("button", { name: "Download JDK 25" })); + expect(run).toHaveBeenCalledWith({ version: 25, languageServer: true }); + off(); + }); + + it("keeps Restart for other failures", () => { + useServers.setState({ servers: new Map([[1 as never, server("crashed", "exit code 1")]]) }); + render(); + fireEvent.click(screen.getByRole("button", { name: /Language servers/ })); + expect(screen.queryByRole("button", { name: /Download JDK/ })).toBeNull(); + expect(screen.getByRole("button", { name: "Restart jdtls" })).toBeInTheDocument(); + }); +}); diff --git a/src/features/code-insight/ServerStatus.tsx b/src/features/code-insight/ServerStatus.tsx index 4c521518f..07115acbc 100644 --- a/src/features/code-insight/ServerStatus.tsx +++ b/src/features/code-insight/ServerStatus.tsx @@ -3,6 +3,7 @@ import { useMemo, useRef, useState } from "react"; import clsx from "clsx"; import { getAction, executeAction } from "@/platform/actions"; import { BarButton, Button, IconButton, Popup, T } from "@/ui"; +import { downloadJdkActions } from "@/platform/jdk"; import { relativePath, useProject } from "@/platform/project"; import { STATUS_LABEL, restartServer, serversSummary, useServers } from "./servers"; import styles from "./ServerStatus.module.css"; @@ -41,45 +42,59 @@ export function ServerStatus() {
Language Servers
- {servers.map((s) => ( -
-
- - {s.name} - {STATUS_LABEL[s.status]} - - {s.status === "missing" && canInstall ? ( - - ) : ( - void restartServer(s.id)} - /> + {servers.map((s) => { + // jdtls without a JDK new enough to run it: "Download JDK 25". + const [jdkFix] = s.status === "misconfigured" ? downloadJdkActions(s.message) : []; + return ( +
+
+ + {s.name} + {STATUS_LABEL[s.status]} + + {jdkFix ? ( + + ) : s.status === "missing" && canInstall ? ( + + ) : ( + void restartServer(s.id)} + /> + )} +
+ {(s.languages.length > 0 || s.root) && ( +
+ {s.languages.join(", ")} + {s.root && s.root !== root ? ` · ${relativePath(root, s.root) || s.root}` : ""} +
+ )} + {s.message && ( +
{s.message}
)}
- {(s.languages.length > 0 || s.root) && ( -
- {s.languages.join(", ")} - {s.root && s.root !== root ? ` · ${relativePath(root, s.root) || s.root}` : ""} -
- )} - {s.message && ( -
{s.message}
- )} -
- ))} + ); + })}
diff --git a/src/features/debugger/actions.ts b/src/features/debugger/actions.ts index 761f2da99..9510f9875 100644 --- a/src/features/debugger/actions.ts +++ b/src/features/debugger/actions.ts @@ -1,6 +1,7 @@ /** Debugger actions (ids match `keymaps/intellij-macos.ts`). */ import { registerActions, type Action } from "@/platform/actions"; import { call, commands, isCancelled, isTauri, nextRequestId, type RunConfig, type RunId } from "@/platform/ipc"; +import { downloadJdkActions } from "@/platform/jdk"; import { useProject } from "@/platform/project"; import { activeEditor } from "@/editor/api"; import { maybeEditorService, notifications, toolWindowService } from "@/workbench/contributions"; @@ -55,7 +56,9 @@ async function report(title: string, p: Promise) { try { await p; } catch (e) { - notifications().notify({ group: "run", level: "error", title, body: errorText(e), key: "debug.action" }); + // A Java launch without a JDK new enough (for jdtls or the project) offers to download one. + const actions = downloadJdkActions(errorText(e)); + notifications().notify({ group: "run", level: "error", title, body: errorText(e), key: "debug.action", actions }); } } diff --git a/src/features/run/producers.ts b/src/features/run/producers.ts index 42e2d304c..a64bec9d4 100644 --- a/src/features/run/producers.ts +++ b/src/features/run/producers.ts @@ -269,7 +269,7 @@ const INSTALL: Record = { r: "Install R (`brew install r`) so that `Rscript` is on the PATH.", go: "Install Go from https://go.dev/dl (or `brew install go`).", rust: "Install Rust with rustup: https://rustup.rs", - java: "Install a JDK 11+ (`brew install openjdk`) so that `java` is on the PATH.", + java: "Install a JDK 11+ (`brew install openjdk`), or download one in Settings › Languages & Frameworks › Java (Project JDK › Download JDK…).", kotlin: "Install the Kotlin compiler (`brew install kotlin`, or https://kotlinlang.org/docs/command-line.html) so that `kotlinc` is on the PATH.", "kotlin-script": "Install Kotlin (`brew install kotlin`).", swift: "Install the Xcode command line tools (`xcode-select --install`).", diff --git a/src/features/run/store.ts b/src/features/run/store.ts index 7f1d15f6f..05270508f 100644 --- a/src/features/run/store.ts +++ b/src/features/run/store.ts @@ -7,6 +7,7 @@ import { create } from "zustand"; import { Channel } from "@tauri-apps/api/core"; import { call, commands, isTauri, type OutputChunk, type RunConfig, type RunId, type TestCaseReport } from "@/platform/ipc"; import { formatCompileErrors, isJvmApplication, parseJavaArgs, resolveJavaCommand } from "@/platform/javaLaunch"; +import { downloadJdkActions } from "@/platform/jdk"; import { useProject } from "@/platform/project"; import { executeAction } from "@/platform/actions"; import { workflowSettings } from "@/platform/workflowSettings"; @@ -590,6 +591,17 @@ async function resolveJvmApplication(sessionId: string, config: RunConfig): Prom if (info.build === "failed") say("Build failed; launching with the last compiled classes.\n", "stderr"); const compileReport = formatCompileErrors(info); if (compileReport) say(compileReport, "stderr"); + // No installed JDK is new enough for the module: offer to download one. + const jdkHint = info.hints.find((h) => downloadJdkActions(h).length); + if (jdkHint) + notifications().notify({ + group: "run", + level: "warning", + key: "run.jdk-missing", + title: `No JDK can run '${config.name}'`, + body: jdkHint, + actions: downloadJdkActions(jdkHint), + }); if (!info.classPaths.length) { say("The classpath is empty: is the module imported by the Java language server?\n", "stderr"); return null; @@ -598,7 +610,9 @@ async function resolveJvmApplication(sessionId: string, config: RunConfig): Prom } catch (e) { const message = e instanceof Error ? e.message : String(e); say(`Cannot resolve the classpath: ${message}\n`, "stderr"); - notifications().notify({ group: "run", level: "error", title: `Cannot run '${config.name}'`, body: message }); + // jdtls can't start without a newer JDK: "Download JDK 25". + const actions = downloadJdkActions(message); + notifications().notify({ group: "run", level: "error", title: `Cannot run '${config.name}'`, body: message, actions }); return null; } } diff --git a/src/features/settings/__tests__/downloadJdk.test.tsx b/src/features/settings/__tests__/downloadJdk.test.tsx new file mode 100644 index 000000000..f6e650d03 --- /dev/null +++ b/src/features/settings/__tests__/downloadJdk.test.tsx @@ -0,0 +1,71 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import type { InstallEvent, Sdk } from "@/platform/ipc"; + +const ok = (data: T) => ({ status: "ok" as const, data }); +vi.mock("@/platform/ipc", async (importOriginal) => { + const real = await importOriginal(); + return { + ...real, + isTauri: () => true, + commands: { + ...real.commands, + jdkDownloadVendors: async () => [{ id: "temurin", name: "Eclipse Temurin" }], + jdkDownloadReleases: async () => + ok({ versions: [26, 25, 21, 17, 11, 8], lts: [8, 11, 17, 21, 25], recommended: 25 }), + jdkDownloadLocation: async () => ok("/data/tools/jdks"), + cancelRequest: async () => ok(null), + }, + }; +}); + +const SDK: Sdk = { + path: "/data/tools/jdks/temurin-25.0.1", + name: "Eclipse Adoptium 25.0.1", + version: "25.0.1", + source: "downloaded", + home: "/data/tools/jdks/temurin-25.0.1", + extra: null, +}; +let finish: (sdk: Sdk) => void = () => {}; +const cancel = vi.fn(); +const downloadJdk = vi.fn((_v: string, _n: number, _l: string | null, onEvent: (e: InstallEvent) => void) => { + onEvent({ type: "progress", fraction: 0.25, message: "Downloaded 50.0 of 200.0 MB" }); + return { promise: new Promise((r) => (finish = r)), cancel }; +}); +vi.mock("@/platform/jdk", async (orig) => ({ ...(await orig()), downloadJdk })); + +const { DownloadJdkDialog } = await import("../languages/DownloadJdkDialog"); +const { useSdks } = await import("../languages/api"); + +afterEach(() => vi.clearAllMocks()); + +describe("Download JDK dialog", () => { + it("downloads the newest LTS into the IDE's JDK folder, with progress, and selects it", async () => { + const onDownloaded = vi.fn(); + const onClose = vi.fn(); + render(); + await waitFor(() => expect(screen.getByRole("button", { name: "Version" })).toHaveTextContent("25")); + expect(screen.getByRole("button", { name: "Vendor" })).toHaveTextContent("Eclipse Temurin"); + await waitFor(() => expect(screen.getByLabelText("Location:")).toHaveValue("/data/tools/jdks")); + + fireEvent.click(screen.getByRole("button", { name: "Download" })); + expect(downloadJdk).toHaveBeenCalledWith("temurin", 25, "/data/tools/jdks", expect.any(Function)); + expect(screen.getByRole("progressbar", { name: "Downloading JDK" })).toHaveAttribute("aria-valuenow", "25"); + expect(screen.getByRole("button", { name: "Download" })).toBeDisabled(); + + // Closing while downloading cancels the download. + fireEvent.keyDown(document.activeElement ?? document.body, { key: "Escape" }); + expect(cancel).toHaveBeenCalled(); + + await act(async () => finish(SDK)); + expect(onDownloaded).toHaveBeenCalledWith(SDK); + expect(useSdks.getState().byKind.jdk?.[0]).toEqual(SDK); + }); + + it("preselects the version a fix asks for", async () => { + render( {}} onDownloaded={() => {}} />); + await waitFor(() => expect(screen.getByRole("button", { name: "Download" })).toBeEnabled()); + expect(screen.getByRole("button", { name: "Version" })).toHaveTextContent("21"); + }); +}); diff --git a/src/features/settings/languages/DownloadJdkDialog.tsx b/src/features/settings/languages/DownloadJdkDialog.tsx new file mode 100644 index 000000000..c01628a72 --- /dev/null +++ b/src/features/settings/languages/DownloadJdkDialog.tsx @@ -0,0 +1,168 @@ +/** + * IDEA's Download JDK dialog: vendor, Java version and location; downloads the + * latest build (SHA-256 verified) with progress and Cancel, then selects it. + */ +import { useEffect, useId, useMemo, useState } from "react"; +import { call, commands, isCancelled, isTauri, nextRequestId, type JdkReleases, type Sdk } from "@/platform/ipc"; +import { downloadJdk, TEMURIN, type JdkDownload } from "@/platform/jdk"; +import { Dialog, IconButton, Input, ProgressBar, Select, type SelectOption, T } from "@/ui"; +import { choosePath, rememberSdk } from "./api"; +import styles from "./Sdk.module.css"; + +const errorText = (e: unknown) => (e instanceof Error ? e.message : String(e)); + +export function DownloadJdkDialog({ + initialVersion, + onClose, + onDownloaded, +}: { + /** Preselected Java version (e.g. the one jdtls needs); default: the newest LTS. */ + initialVersion?: number; + onClose: () => void; + onDownloaded: (sdk: Sdk) => void; +}) { + const [vendors, setVendors] = useState([{ value: TEMURIN, label: "Eclipse Temurin" }]); + const [vendor, setVendor] = useState(TEMURIN); + const [releases, setReleases] = useState(null); + const [version, setVersion] = useState(initialVersion ? String(initialVersion) : null); + const [location, setLocation] = useState(""); + const [job, setJob] = useState(null); + const [progress, setProgress] = useState<{ fraction: number | null; message: string } | null>(null); + const [error, setError] = useState(null); + const locationId = useId(); + + useEffect(() => { + if (!isTauri()) return; + commands + .jdkDownloadVendors() + .then((list) => setVendors(list.map((v) => ({ value: v.id, label: v.name })))) + .catch(() => {}); + void call(commands.jdkDownloadLocation()) + .then((dir) => setLocation((cur) => cur || dir)) + .catch(() => {}); + }, []); + + useEffect(() => { + if (!isTauri()) return; + const request = nextRequestId(); + let live = true; + call(commands.jdkDownloadReleases(vendor, request)) + .then((r) => { + if (!live) return; + setReleases(r); + setVersion((v) => (v && r.versions.includes(Number(v)) ? v : String(r.recommended))); + }) + .catch((e: unknown) => live && !isCancelled(e) && setError(`Cannot list the Java versions: ${errorText(e)}`)); + return () => { + live = false; + void commands.cancelRequest(request).catch(() => {}); + }; + }, [vendor]); + + const versionOptions = useMemo( + () => + (releases?.versions ?? []).map((v) => ({ + value: String(v), + label: String(v), + detail: releases?.lts.includes(v) ? "LTS" : undefined, + })), + [releases], + ); + + const download = () => { + if (!version) return; + setError(null); + setProgress({ fraction: null, message: "Resolving the latest build…" }); + const next = downloadJdk(vendor, Number(version), location.trim() || null, (e) => + setProgress((p) => + e.type === "progress" + ? { fraction: e.fraction, message: e.message } + : { fraction: p?.fraction ?? null, message: e.text }, + ), + ); + setJob(next); + next.promise + .then((sdk) => { + rememberSdk("jdk", sdk); + onDownloaded(sdk); + }) + .catch((e: unknown) => { + if (!isCancelled(e)) setError(errorText(e)); + setProgress(null); + }) + .finally(() => setJob(null)); + }; + + const vendorName = vendors.find((v) => v.value === vendor)?.label ?? vendor; + return ( + (job ? job.cancel() : onClose())} + title="Download JDK" + width={520} + aria-label="Download JDK" + buttons={[ + { label: "Download", role: "default", disabled: !version || !!job, onClick: download }, + { label: "Cancel", role: "cancel" }, + ]} + > +
+
+ + Vendor: + + + +
+ setLocation(e.target.value)} + /> + { + const p = await choosePath({ directory: true, title: "JDK Location", defaultPath: location }); + if (p) setLocation(p); + }} + /> +
+
+
+ {vendorName} {version ?? ""} is unpacked into a folder of its own in {location || "…"}, where JDK detection + finds it. +
+ {progress && ( +
+ + {progress.message} +
+ )} + {error && ( +
+ {error} +
+ )} +
+
+ ); +} diff --git a/src/features/settings/languages/LanguagePages.tsx b/src/features/settings/languages/LanguagePages.tsx index efe571a65..eca7dfe98 100644 --- a/src/features/settings/languages/LanguagePages.tsx +++ b/src/features/settings/languages/LanguagePages.tsx @@ -244,7 +244,7 @@ export function JavaPage() { directory browseTitle="Select JDK Home Directory" aria-label="Language server JDK" - hint="Runs jdtls only, like IDEA's JDK for importer; the project keeps compiling and running with the Project JDK. jdtls needs JDK 21 or newer (recent releases 25). The jdtls executable itself is set in Language Servers." + hint="Runs jdtls only, like IDEA's JDK for importer; the project keeps compiling and running with the Project JDK. jdtls needs JDK 21 or newer (recent releases 25); Download JDK… in the list fetches one. The jdtls executable itself is set in Language Servers." /> o.value === value)) out.push({ value, label: tildify(value), detail: "custom" }); + if (kind === "jdk") out.push({ value: DOWNLOAD_JDK, label: "Download JDK…", icon: "plus" }); return out; - }, [list, value, emptyLabel, emptyDetail, extra]); + }, [list, value, emptyLabel, emptyDetail, extra, kind]); + const [downloading, setDownloading] = useState(false); const browse = async () => { const picked = await choosePath({ directory: !!directory, title: browseTitle, defaultPath: value || undefined }); if (picked) onChange(picked); @@ -79,7 +85,7 @@ export function SdkField({ className={styles.sdkSelect} value={value} options={options} - onChange={onChange} + onChange={(v) => (v === DOWNLOAD_JDK ? setDownloading(true) : onChange(v))} aria-label={aria["aria-label"]} placeholder={loading ? "Detecting…" : ""} /> @@ -89,6 +95,15 @@ export function SdkField({ )} void browse()} /> + {downloading && ( + setDownloading(false)} + onDownloaded={(sdk) => { + setDownloading(false); + onChange(sdk.path); + }} + /> + )} ); } diff --git a/src/features/tools/__tests__/jdk.test.ts b/src/features/tools/__tests__/jdk.test.ts new file mode 100644 index 000000000..f84952a14 --- /dev/null +++ b/src/features/tools/__tests__/jdk.test.ts @@ -0,0 +1,116 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { InstallEvent, Sdk, Settings } from "@/bindings"; +import type { NotificationService } from "@/workbench/contributions"; + +const settingsSet = vi.fn(async (_scope: string, _key: string, value: string) => ({ + status: "ok" as const, + data: { languages: { java: { jdtlsJdk: JSON.parse(value) } } } as unknown as Settings, +})); +vi.mock("@/platform/ipc", async (orig) => { + const actual = await orig(); + return { ...actual, commands: { ...actual.commands, settingsSet } }; +}); + +const SDK: Sdk = { + path: "/data/tools/jdks/temurin-25.0.1/Contents/Home", + name: "Eclipse Adoptium 25.0.1", + version: "25.0.1", + source: "downloaded", + home: "/data/tools/jdks/temurin-25.0.1/Contents/Home", + extra: null, +}; +let outcome: () => Promise; +const cancel = vi.fn(); +const downloadJdk = vi.fn( + (_vendor: string, _version: number, _location: string | null, onEvent: (e: InstallEvent) => void) => { + onEvent({ type: "progress", fraction: 0.5, message: "Downloaded 100.0 of 200.0 MB" }); + return { promise: outcome(), cancel }; + }, +); +vi.mock("@/platform/jdk", async (orig) => ({ ...(await orig()), downloadJdk })); + +const { downloadJdkFor } = await import("../jdk"); +const { useProject } = await import("@/platform/project"); +const { IpcError } = await import("@/platform/ipc"); +const { provideNotificationService } = await import("@/workbench/contributions"); +const { useTasks } = await import("@/workbench/tasks"); + +type Note = Parameters[0]; +let notes: Note[]; + +const withServerJdk = (jdtlsJdk: string, project = true) => + useProject.setState({ + project: project ? ({ root: "/p" } as never) : null, + settings: { languages: { java: { jdk: "", jdtlsJdk } } } as never, + }); + +beforeEach(() => { + notes = []; + provideNotificationService({ notify: (n) => void notes.push(n) }); + outcome = async () => SDK; +}); + +afterEach(() => { + useProject.setState({ project: null, settings: null }); + vi.clearAllMocks(); +}); + +describe("Download JDK N (one click)", () => { + it("downloads Temurin N into the IDE's JDK folder, with progress in the background tasks", async () => { + withServerJdk(""); + const seen: Array = []; + const unsub = useTasks.subscribe((s) => { + const t = s.tasks.find((x) => x.id === "jdk-download:25"); + if (t && !t.done) seen.push(t.fraction); + }); + const sdk = await downloadJdkFor({ version: 25, languageServer: true }); + unsub(); + expect(sdk).toEqual(SDK); + expect(downloadJdk).toHaveBeenCalledWith("temurin", 25, null, expect.any(Function)); + expect(seen).toContain(0.5); + // Auto: the backend's rescan makes jdtls pick the new JDK by itself. + expect(settingsSet).not.toHaveBeenCalled(); + expect(notes.at(-1)).toMatchObject({ level: "info", title: "Eclipse Adoptium 25.0.1 downloaded" }); + }); + + it("replaces a language server JDK that is too old (in the project, whose value wins)", async () => { + withServerJdk("C:\\Java\\jdk-21"); + await downloadJdkFor({ version: 25, languageServer: true }); + expect(settingsSet).toHaveBeenCalledWith("project", "languages.java.jdtlsJdk", JSON.stringify(SDK.path)); + expect(useProject.getState().settings?.languages?.java?.jdtlsJdk).toBe(SDK.path); + + withServerJdk("/old", false); + await downloadJdkFor({ version: 25, languageServer: true }); + expect(settingsSet).toHaveBeenLastCalledWith("global", "languages.java.jdtlsJdk", JSON.stringify(SDK.path)); + }); + + it("leaves the settings alone for a project JDK (Auto picks it per module)", async () => { + withServerJdk("/old"); + await downloadJdkFor({ version: 21 }); + expect(settingsSet).not.toHaveBeenCalled(); + }); + + it("deduplicates concurrent downloads of one version", async () => { + withServerJdk(""); + const a = downloadJdkFor({ version: 25 }); + const b = downloadJdkFor({ version: 25 }); + expect(a).toBe(b); + await a; + expect(downloadJdk).toHaveBeenCalledTimes(1); + }); + + it("reports failures but not cancellation", async () => { + withServerJdk(""); + outcome = async () => { + throw new IpcError({ kind: "unavailable", message: "Eclipse Temurin 8 has no build for Linux Arm64" }); + }; + expect(await downloadJdkFor({ version: 8 })).toBeNull(); + expect(notes.at(-1)).toMatchObject({ level: "error", title: "Failed to download JDK 8" }); + notes = []; + outcome = async () => { + throw new IpcError({ kind: "cancelled", message: "cancelled" }); + }; + expect(await downloadJdkFor({ version: 8 })).toBeNull(); + expect(notes).toEqual([]); + }); +}); diff --git a/src/features/tools/index.ts b/src/features/tools/index.ts index 075736a2f..37993487d 100644 --- a/src/features/tools/index.ts +++ b/src/features/tools/index.ts @@ -2,11 +2,13 @@ * Tools installer: Settings › Languages & Frameworks › Language Servers, plus * install prompts for missing language servers. * - * Actions: `tools.languageServers` (open the page), `tools.install` (arg: tool name). + * Actions: `tools.languageServers` (open the page), `tools.install` (arg: tool name), + * `java.downloadJdk` (arg: `DownloadJdkArgs`). */ import { lazyComponent } from "@/ui"; import { executeAction, registerActions } from "@/platform/actions"; import { registerSettingsPage } from "@/workbench/contributions"; +import { installJdkPrompts, registerJdkActions } from "./jdk"; import { installMissingServerPrompts } from "./missing"; import { installTool, refreshTools, SETTINGS_PAGE_ID, toolByName, useTools } from "./store"; @@ -55,6 +57,8 @@ export function activate(): () => void { }, ]), installMissingServerPrompts(), + registerJdkActions(), + installJdkPrompts(), ]; return () => disposers.reverse().forEach((d) => d()); } diff --git a/src/features/tools/jdk.ts b/src/features/tools/jdk.ts new file mode 100644 index 000000000..f0479d89c --- /dev/null +++ b/src/features/tools/jdk.ts @@ -0,0 +1,122 @@ +/** + * `java.downloadJdk` (arg: `DownloadJdkArgs`): the one-click fix offered where + * jdtls, a run configuration or a Maven / Gradle sync lacks a new enough JDK. + * Downloads Eclipse Temurin N into the IDE's JDK folder (found by JDK detection + * from then on), makes it the language server JDK when a too-old one is chosen + * there, and lets the backend restart jdtls on it. Progress and Cancel live in + * the status bar's background tasks. + */ +import type { Sdk } from "@/bindings"; +import { executeAction, registerActions } from "@/platform/actions"; +import { call, commands, isCancelled, isTauri, onIdeEvent } from "@/platform/ipc"; +import { + DOWNLOAD_JDK_ACTION, + downloadJdk, + downloadJdkActions, + isDownloadJdkArgs, + requiredJdk, + TEMURIN, + type DownloadJdkArgs, +} from "@/platform/jdk"; +import { useProject } from "@/platform/project"; +import { notifications } from "@/workbench/contributions"; +import { upsertTask } from "@/workbench/tasks"; + +const JAVA_SETTINGS = "languages.java"; +const running = new Map>(); + +/** Makes `home` the language server JDK if one that can't run jdtls is chosen (Auto finds the new JDK by itself). */ +export async function chooseForLanguageServer(home: string): Promise { + const chosen = useProject.getState().settings?.languages?.java?.jdtlsJdk?.trim(); + if (!chosen || chosen === home) return; + // The project value wins over the global one, so writing it there always takes effect. + const scope = useProject.getState().project ? "project" : "global"; + const settings = await call(commands.settingsSet(scope, "languages.java.jdtlsJdk", JSON.stringify(home))); + useProject.setState({ settings }); +} + +export function downloadJdkFor(args: DownloadJdkArgs): Promise { + const existing = running.get(args.version); + if (existing) return existing; + const title = `Downloading JDK ${args.version}`; + const taskId = `jdk-download:${args.version}`; + const key = `jdk-download-${args.version}`; + const cancel = () => job.cancel(); + const job = downloadJdk(TEMURIN, args.version, null, (e) => { + const message = e.type === "line" ? e.text : e.message; + const fraction = e.type === "progress" ? e.fraction : null; + upsertTask({ id: taskId, title, message, fraction, done: false, cancel }); + }); + upsertTask({ id: taskId, title, message: "Eclipse Temurin", fraction: null, done: false, cancel }); + const p = (async () => { + try { + const sdk = await job.promise; + if (args.languageServer) await chooseForLanguageServer(sdk.path); + notifications().notify({ + group: "tools", + level: "info", + key, + title: `${sdk.name} downloaded`, + body: args.languageServer + ? "The Java language server restarts on it." + : `Installed in ${sdk.path}; it is used for projects targeting Java ${args.version}.`, + }); + return sdk; + } catch (e) { + if (isCancelled(e)) return null; + notifications().notify({ + group: "tools", + level: "error", + key, + title: `Failed to download JDK ${args.version}`, + body: e instanceof Error ? e.message : String(e), + actions: [{ label: "Java Settings…", run: () => void executeAction("file.settings", JAVA_SETTINGS) }], + }); + return null; + } finally { + upsertTask({ id: taskId, title, message: null, fraction: 1, done: true }); + running.delete(args.version); + } + })(); + running.set(args.version, p); + return p; +} + +/** A language server that can't start without a newer JDK (jdtls): say so once, with the one-click download. */ +export function installJdkPrompts(): () => void { + const prompted = new Set(); + return onIdeEvent("languageServer", (e) => { + if (e.status !== "misconfigured") return; + const need = requiredJdk(e.message); + if (!need || prompted.has(`${e.name}:${need.version}`)) return; + prompted.add(`${e.name}:${need.version}`); + notifications().notify({ + group: "lsp", + level: "warning", + key: `jdk-required-${e.name}`, + sticky: true, + title: `${e.name} needs JDK ${need.version} to run`, + body: e.message ?? undefined, + actions: [ + ...downloadJdkActions(e.message), + { label: "Java Settings…", run: () => void executeAction("file.settings", JAVA_SETTINGS) }, + ], + }); + }); +} + +export function registerJdkActions(): () => void { + return registerActions([ + { + id: DOWNLOAD_JDK_ACTION, + title: "Download JDK…", + category: "Java", + hidden: true, + // Without a version: the Java settings, whose JDK pickers offer Download JDK…. + run: async ({ arg }) => { + if (isDownloadJdkArgs(arg) && isTauri()) await downloadJdkFor(arg); + else await executeAction("file.settings", JAVA_SETTINGS); + }, + }, + ]); +} diff --git a/src/platform/jdk.test.ts b/src/platform/jdk.test.ts new file mode 100644 index 000000000..da541f25a --- /dev/null +++ b/src/platform/jdk.test.ts @@ -0,0 +1,63 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const executeAction = vi.fn(async () => true); +let registered = true; +vi.mock("./actions", async (orig) => ({ + ...(await orig()), + executeAction, + getAction: (id: string) => (registered && id === "java.downloadJdk" ? { id } : undefined), +})); + +const { downloadJdkActions, isDownloadJdkArgs, requiredJdk } = await import("./jdk"); + +afterEach(() => { + registered = true; + vi.clearAllMocks(); +}); + +// Issue #49, verbatim from the run console (Windows, JDK 8 / 17 / 21 installed). +const ISSUE_49 = + "jdtls needs JDK 25 or newer to run (found JDK 8, 17, 21). Pick one in Settings › Languages & Frameworks › Java › Language Server › JDK (it restarts automatically), or install a newer JDK and restart the server; the project can still target an older Java version."; + +describe("JDK requirements in launch / sync errors", () => { + it.each([ + [ISSUE_49, { version: 25, languageServer: true }], + [ + "The language server JDK (C:\\Java\\jdk-21, JDK 21) is too old: jdtls needs JDK 25 or newer. Download JDK 25, choose another one in Settings › Languages & Frameworks › Java › Language Server › JDK, or set it to Auto.", + { version: 25, languageServer: true }, + ], + [ + "This project targets Java 21: it needs JDK 21 or newer (found JDK 17). Download JDK 21, or choose a Project JDK in Settings › Languages & Frameworks › Java.", + { version: 21, languageServer: false }, + ], + [ + "This project targets Java 17: it needs JDK 17 or newer (no JDK was found). Download JDK 17, or choose a Project JDK in Settings › Languages & Frameworks › Java.\n[ERROR] Failed to execute goal", + { version: 17, languageServer: false }, + ], + ["The language server JDK /x is not a JDK home (Settings › Languages & Frameworks › Java).", null], + ["`rust-analyzer` was not found on PATH", null], + [null, null], + ])("%s", (message, expected) => { + expect(requiredJdk(message)).toEqual(expected); + }); + + it("offers Download JDK N, which runs the download action", () => { + const [fix, ...rest] = downloadJdkActions(ISSUE_49); + expect(rest).toEqual([]); + expect(fix!.label).toBe("Download JDK 25"); + fix!.run(); + expect(executeAction).toHaveBeenCalledWith("java.downloadJdk", { version: 25, languageServer: true }); + expect(downloadJdkActions("jdtls crashed")).toEqual([]); + }); + + it("offers nothing where no window can download (e.g. without the tools feature)", () => { + registered = false; + expect(downloadJdkActions(ISSUE_49)).toEqual([]); + }); + + it("recognises the action's argument", () => { + expect(isDownloadJdkArgs({ version: 25 })).toBe(true); + expect(isDownloadJdkArgs({ version: "25" })).toBe(false); + expect(isDownloadJdkArgs(undefined)).toBe(false); + }); +}); diff --git a/src/platform/jdk.ts b/src/platform/jdk.ts new file mode 100644 index 000000000..e0517311f --- /dev/null +++ b/src/platform/jdk.ts @@ -0,0 +1,67 @@ +/** + * Download JDK (`jdk_download*` commands), shared by the JDK pickers in Settings + * and the "Download JDK N" fixes offered where a launch or sync lacks a JDK. + * The one-click fix is the `java.downloadJdk` action (features/tools). + */ +import { Channel } from "@tauri-apps/api/core"; +import { executeAction, getAction } from "./actions"; +import { call, commands, nextRequestId, type InstallEvent, type Sdk } from "./ipc"; + +export const DOWNLOAD_JDK_ACTION = "java.downloadJdk"; +export const TEMURIN = "temurin"; + +export interface DownloadJdkArgs { + /** Java feature version, e.g. 25. */ + version: number; + /** Run jdtls on it: chosen as the language server JDK unless Auto already picks it. */ + languageServer?: boolean; +} + +export interface JdkRequirement { + version: number; + /** The JDK runs the Java language server (jdtls), not the project. */ + languageServer: boolean; +} + +/** + * The JDK a launch / sync error asks for: the backend's messages say + * "jdtls needs JDK 25 or newer" / "it needs JDK 21 or newer". + */ +export function requiredJdk(message: string | null | undefined): JdkRequirement | null { + const m = /needs JDK (\d+) or newer/.exec(message ?? ""); + if (!m) return null; + return { version: Number(m[1]), languageServer: /\bjdtls\b|language server JDK/i.test(message ?? "") }; +} + +/** "Download JDK N" notification / banner actions for `message`; none when it names no JDK or nothing can download. */ +export function downloadJdkActions(message: string | null | undefined): Array<{ label: string; run: () => void }> { + const need = requiredJdk(message); + if (!need || !getAction(DOWNLOAD_JDK_ACTION)) return []; + const args: DownloadJdkArgs = { version: need.version, languageServer: need.languageServer }; + return [{ label: `Download JDK ${need.version}`, run: () => void executeAction(DOWNLOAD_JDK_ACTION, args) }]; +} + +export function isDownloadJdkArgs(arg: unknown): arg is DownloadJdkArgs { + return !!arg && typeof arg === "object" && typeof (arg as { version?: unknown }).version === "number"; +} + +export interface JdkDownload { + promise: Promise; + cancel: () => void; +} + +/** Downloads the latest `vendor` JDK `version` into `location` (null: the IDE's JDK folder). */ +export function downloadJdk( + vendor: string, + version: number, + location: string | null, + onEvent: (e: InstallEvent) => void, +): JdkDownload { + const request = nextRequestId(); + const channel = new Channel(); + channel.onmessage = onEvent; + return { + promise: call(commands.jdkDownload(vendor, version, location, request, channel)), + cancel: () => void commands.cancelRequest(request).catch(() => {}), + }; +}