From ba217ca9ad431bc53ee179e5f5bde0cdc66577d9 Mon Sep 17 00:00:00 2001 From: johnson Date: Sat, 26 Sep 2026 01:24:29 +0800 Subject: [PATCH 1/2] feat(windows): support Maven Daemon (mvnd) Relates to 1lck/Lithe-IDEA#889 --- .../2026-09-25-windows-mvnd-toolchain.md | 46 ++++++ docs/development/platform-parity-matrix.csv | 1 + docs/development/platform-parity-matrix.md | 9 +- rust/lithe-core/src/project/maven.rs | 29 +++- rust/lithe-core/src/tests/languages.rs | 54 +++++++ shared/platform-feature-matrix.json | 26 ++++ windows/tauri/src-tauri/src/run.rs | 138 +++++++++++++++--- windows/tauri/src/i18n/locale.ts | 20 +-- 8 files changed, 290 insertions(+), 33 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-09-25-windows-mvnd-toolchain.md diff --git a/.agents/notes/implemented/feature/2026-09-25-windows-mvnd-toolchain.md b/.agents/notes/implemented/feature/2026-09-25-windows-mvnd-toolchain.md new file mode 100644 index 000000000..ab5e48ae7 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-25-windows-mvnd-toolchain.md @@ -0,0 +1,46 @@ +# Agent 笔记:Windows Maven 工具链识别 mvnd + +状态:已实现 + +## 先说结论 + +Windows 的 Maven 选择器接受 Maven Daemon(mvnd,复用后台 Java 进程执行 Maven 构建)的安装主目录、bin 目录和启动文件。它继续复用现有 Maven 导入、构建和运行流程,不增加第二套项目模型。Java 语言服务器使用 mvnd 内嵌 Maven 的全局配置,避免构建与代码分析读取不同的镜像设置。 + +## 问题 + +原目录解析只尝试 `bin/mvn.*`,选择 mvnd 的根目录无法得到启动文件。mvnd 的版本输出先包含 `Apache Maven Daemon`,旧解析器误把 `Daemon` 当成版本;它的 Maven 全局设置位于 `mvn/conf/settings.xml`,并非发行包根目录的 `conf/settings.xml`。 + +## 决策 + +- Windows 的工具链发现、无进程导入解析和执行解析共用目录候选生成器,支持 mvnd 原生客户端及脚本入口。自动模式仍优先考虑项目 Wrapper 和已有 Maven,之后补充 `MVND_HOME` 与 PATH 中的 mvnd。 +- 显式目录选择必须解析到该目录的客户端,不得回退到系统 Maven。正确示例是选择 mvnd 根目录后执行 `bin/mvnd.exe`;不要改为执行其内嵌的 `mvn/bin/mvn.cmd`,否则用户没有真正用到守护进程。 +- 版本字段保留已有 Maven 版本语义,从 mvnd 输出的内嵌 Maven 行解析版本,跳过 Daemon 标题。 +- Core 只负责确定性地解析全局设置路径,识别已安装 mvnd 的 `mvn/conf/settings.xml`。JDT LS(Java 语言服务器)的项目模型仍由上游管理;这不是让 JDT LS 经由 mvnd 导入项目。 +- 不增加全局 `mvnd --stop` 行为。mvnd 的守护进程和复用由上游客户端管理,不能关闭用户其他终端的构建服务。真实验证使用独立的临时守护进程注册目录,并在结束后清理。 + +## 考虑过的备选方案 + +仅让用户填写 `mvnd.exe` 会保留目录选择失败、错误版本和全局配置不一致的问题。退回内嵌普通 Maven 虽可执行相同目标,却违背用户选择 mvnd 的意图。另建 mvnd 项目模型会重复 Maven/JDT LS 的责任,因此均未采用。 + +## 后果 + +项目环境中的 Maven/mvnd 自动识别入口显示“自动”,手动选择后显示用户路径;没有检测到 Maven 或 mvnd 时,明确提示填写或选择安装目录。实际解析路径和失败提示继续由有效工具链状态展示。 + +既有 Maven 设置字段和运行配置无需迁移,用户可直接保存 mvnd 目录。代价是需要覆盖 Maven 与 mvnd 的不同布局;本次 Windows 发现入口之外的 macOS 自动发现尚未增加 mvnd 支持。mvnd 自身的 Java 配置和构建插件兼容性仍由该工具负责。 + +## 验证 + +- `.agents/skills/write-stable-tests/scripts/verify-test-stability.ps1` 检查新增测试的有界等待与资源清理。 +- `.agents/skills/write-stable-tests/scripts/test-stability-windows.ps1` 的 WindowsRust 与 SharedRust 范围记录独立测试时间。 +- `windows/tauri/src-tauri/src/run.rs` 的 mvnd 用例覆盖根目录、bin、启动文件、含空格路径、显式选择优先级、无效路径和版本解析;默认单测不启动外部工具。真实目录集成测试需要显式设置 `LITHE_TEST_MVND_HOME` 并运行 ignored 用例。 +- `rust/lithe-core/src/tests/languages.rs` 验证 mvnd 三种路径都向 JDT LS 提供内嵌 Maven 的设置;文件缺失时不使用错误的根目录设置。 +- `scripts/build-windows.ps1 -Configuration Release` 验证 Windows 宿主和前端构建。 + +## 适用范围 + +- `windows/tauri/src-tauri/src/run.rs` +- `windows/tauri/src/i18n/locale.ts` +- `rust/lithe-core/src/project/maven.rs` +- `rust/lithe-core/src/tests/languages.rs` + +上游依据:[Apache Maven Daemon](https://github.com/apache/maven-mvnd);本机 mvnd 1.0.6 的版本输出和发行包布局用于实机验证,机器路径不写入产品源码。 diff --git a/docs/development/platform-parity-matrix.csv b/docs/development/platform-parity-matrix.csv index 02d529709..827b5b13f 100644 --- a/docs/development/platform-parity-matrix.csv +++ b/docs/development/platform-parity-matrix.csv @@ -23,6 +23,7 @@ ai-chat-session,AI,AI 对话,对话会话与流式响应,未实现,不适用,已 ai-chat-history,AI,AI 对话,对话历史、置顶与归档,未实现,不适用,已实现,待验证,AI,Windows 验证历史加载、重命名、置顶、归档、删除和重启后持久化。,,macos/Sources/Lithe/Platform/MacOS/AI,windows/tauri/src/features/ai/services/ai-chat-history-service.ts; windows/tauri/src/features/layout/components/sidebar/sidebar-history.tsx ai-chat-providers,AI,AI 对话,多 Provider 与工作区范围,未实现,不适用,已实现,待验证,AI,Windows 验证 Provider 切换、凭据边界、工作区范围和模型错误提示。,,macos/Sources/Lithe/Platform/MacOS/AI,windows/tauri/src/features/ai/services/providers; windows/tauri/src/features/ai/lib/ai-workspace-scope.ts java-runtime-discovery,Java,Java/Maven,JDK 与 Maven 工具链发现,已实现,待验证,已实现,待验证,Java / Maven,配置多个 JDK/Maven 候选,验证发现、选择、版本不匹配和错误提示。,,macos/Sources/Lithe/Services/Java; macos/Sources/Lithe/Views/Run,windows/tauri/src/features/maven; windows/tauri/src/features/workspace +windows-mvnd-toolchain,Java,Java/Maven,Windows mvnd 工具链目录选择与 Maven 全局配置,平台专属,不适用,已实现,待验证,Java / Maven,在 Windows 选择 mvnd 根目录、bin 目录和启动文件,验证发现、版本、显式执行路径以及 JDT LS 全局配置;真实 mvnd 执行最小 Maven 构建。 自动入口仅显示“自动”,手动选择显示路径,未找到时提示选择安装目录。,,.agents/notes/implemented/feature/2026-09-25-windows-mvnd-toolchain.md,windows/tauri/src-tauri/src/run.rs; rust/lithe-core/src/project/maven.rs; rust/lithe-core/src/tests/languages.rs; .agents/notes/implemented/feature/2026-09-25-windows-mvnd-toolchain.md; windows/tauri/src/i18n/locale.ts java-maven-project,Java,Java/Maven,项目根、模块与源码集识别,已实现,待验证,已实现,待验证,Java / Maven,使用单模块、多模块和非标准源码目录 fixture 对比项目模型。,,macos/Sources/Lithe/Services/Java; shared/contracts/application-boundary.md,windows/tauri/src/features/maven; windows/tauri/src/features/workspace java-maven-profiles-dependencies,Java,Java/Maven,Profile 与依赖树,已实现,待验证,已实现,待验证,Java / Maven,切换 Maven profile 并刷新依赖树,确认模块归属、顺序和错误边界。,,macos/Sources/Lithe/Views/Run; macos/Sources/Lithe/Services/Java,windows/tauri/src/features/maven java-build-diagnostics,Java,Java/Maven,构建输出与编译诊断,已实现,待验证,已实现,待验证,Java / Maven,使用成功、编译失败和进程失败构建 fixture,对比诊断位置、输出和退出状态。,,macos/Sources/Lithe/Views/Run; macos/Sources/Lithe/Services/Diagnostics,windows/tauri/src/features/maven; windows/tauri/src/features/diagnostics diff --git a/docs/development/platform-parity-matrix.md b/docs/development/platform-parity-matrix.md index 8038ee37f..f23b37bd5 100644 --- a/docs/development/platform-parity-matrix.md +++ b/docs/development/platform-parity-matrix.md @@ -4,9 +4,9 @@ - 最后复核:2026-09-24 - 盘点状态:initial-static-inventory(根据 macOS Views/Application/Services、Windows features/extensions 和共享契约的代码入口进行初版盘点;未替代真实运行验收。) -- 功能项:76 -- macOS:实现:✅ 64 已实现,🟡 3 部分实现,❌ 7 未实现,🧩 2 平台专属;验证:✔️ 0 已验证,🔍 67 待验证,— 9 不适用 -- Windows:实现:✅ 71 已实现,🟡 3 部分实现,❌ 2 未实现,🧩 0 平台专属;验证:✔️ 0 已验证,🔍 74 待验证,— 2 不适用 +- 功能项:77 +- macOS:实现:✅ 64 已实现,🟡 3 部分实现,❌ 7 未实现,🧩 3 平台专属;验证:✔️ 0 已验证,🔍 67 待验证,— 10 不适用 +- Windows:实现:✅ 72 已实现,🟡 3 部分实现,❌ 2 未实现,🧩 0 平台专属;验证:✔️ 0 已验证,🔍 75 待验证,— 2 不适用 ## 实现状态定义 @@ -112,11 +112,12 @@
-Java · 11 个能力点 +Java · 12 个能力点 | 功能组 | 能力点 | macOS | Windows | 负责人 | 验证方式 | 备注 | | --- | --- | --- | --- | --- | --- | --- | | Java/Maven | **JDK 与 Maven 工具链发现**
java-runtime-discovery | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Services/Java`、`macos/Sources/Lithe/Views/Run` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/maven`、`windows/tauri/src/features/workspace` | Java / Maven | 配置多个 JDK/Maven 候选,验证发现、选择、版本不匹配和错误提示。 | | +| Java/Maven | **Windows mvnd 工具链目录选择与 Maven 全局配置**
windows-mvnd-toolchain | 🧩 平台专属
— 不适用
`.agents/notes/implemented/feature/2026-09-25-windows-mvnd-toolchain.md` | ✅ 已实现
🔍 待验证
`windows/tauri/src-tauri/src/run.rs`、`rust/lithe-core/src/project/maven.rs`、`rust/lithe-core/src/tests/languages.rs`、`.agents/notes/implemented/feature/2026-09-25-windows-mvnd-toolchain.md`、`windows/tauri/src/i18n/locale.ts` | Java / Maven | 在 Windows 选择 mvnd 根目录、bin 目录和启动文件,验证发现、版本、显式执行路径以及 JDT LS 全局配置;真实 mvnd 执行最小 Maven 构建。 自动入口仅显示“自动”,手动选择显示路径,未找到时提示选择安装目录。 | | | Java/Maven | **项目根、模块与源码集识别**
java-maven-project | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Services/Java`、`shared/contracts/application-boundary.md` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/maven`、`windows/tauri/src/features/workspace` | Java / Maven | 使用单模块、多模块和非标准源码目录 fixture 对比项目模型。 | | | Java/Maven | **Profile 与依赖树**
java-maven-profiles-dependencies | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Run`、`macos/Sources/Lithe/Services/Java` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/maven` | Java / Maven | 切换 Maven profile 并刷新依赖树,确认模块归属、顺序和错误边界。 | | | Java/Maven | **构建输出与编译诊断**
java-build-diagnostics | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Run`、`macos/Sources/Lithe/Services/Diagnostics` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/maven`、`windows/tauri/src/features/diagnostics` | Java / Maven | 使用成功、编译失败和进程失败构建 fixture,对比诊断位置、输出和退出状态。 | | diff --git a/rust/lithe-core/src/project/maven.rs b/rust/lithe-core/src/project/maven.rs index dccc63fa3..f01e75a53 100644 --- a/rust/lithe-core/src/project/maven.rs +++ b/rust/lithe-core/src/project/maven.rs @@ -465,7 +465,15 @@ fn settings_parse_error(error: quick_xml::Error) -> CoreError { fn installation_settings_path(maven_executable_path: Option<&str>) -> Option { let configured = Path::new(maven_executable_path?); let home = if configured.is_dir() { - configured.to_path_buf() + if configured + .file_name()? + .to_str()? + .eq_ignore_ascii_case("bin") + { + configured.parent()?.to_path_buf() + } else { + configured.to_path_buf() + } } else { // Only the `/bin/mvn*` layout identifies an installation root. let bin = configured.parent()?; @@ -474,7 +482,24 @@ fn installation_settings_path(maven_executable_path: Option<&str>) -> Option").unwrap(); + // The daemon's conf is not the embedded Maven installation's conf. + fs::write(home.join("conf/settings.xml"), "").unwrap(); + for configured in [home.clone(), home.join("bin"), launcher] { + let configuration = jdt_configuration( + fixture.0.to_str().unwrap(), + maven_jdt_context(Some(configured.to_string_lossy().into_owned())), + ) + .unwrap(); + assert_eq!( + configuration.global_settings_path.as_deref(), + settings.to_str() + ); + } + // Explicit ordinary Maven must not inherit the adjacent daemon's settings. + let maven_launcher = home.join("bin").join("mvn.cmd"); + fs::write(&maven_launcher, "fixture; never executed").unwrap(); + let configuration = jdt_configuration( + fixture.0.to_str().unwrap(), + maven_jdt_context(Some(maven_launcher.to_string_lossy().into_owned())), + ) + .unwrap(); + let maven_settings = home.join("conf").join("settings.xml"); + assert_eq!( + configuration.global_settings_path.as_deref(), + maven_settings.to_str() + ); + fs::remove_file(settings).unwrap(); + let configuration = jdt_configuration( + fixture.0.to_str().unwrap(), + maven_jdt_context(Some(home.to_string_lossy().into_owned())), + ) + .unwrap(); + assert_eq!(configuration.global_settings_path, None); +} + #[test] fn maven_installation_settings_are_absent_for_wrappers_and_bare_installations() { let root = maven_reactor_root("maven-jdt-global-settings-absent"); diff --git a/shared/platform-feature-matrix.json b/shared/platform-feature-matrix.json index 6e4500a00..ed623fa00 100644 --- a/shared/platform-feature-matrix.json +++ b/shared/platform-feature-matrix.json @@ -604,6 +604,32 @@ "owner": "Java / Maven", "verification": "配置多个 JDK/Maven 候选,验证发现、选择、版本不匹配和错误提示。" }, + { + "id": "windows-mvnd-toolchain", + "area": "Java", + "group": "Java/Maven", + "capability": "Windows mvnd 工具链目录选择与 Maven 全局配置", + "macos": { + "evidence": [ + ".agents/notes/implemented/feature/2026-09-25-windows-mvnd-toolchain.md" + ], + "implementationStatus": "platform-specific", + "verificationStatus": "not-applicable" + }, + "windows": { + "evidence": [ + "windows/tauri/src-tauri/src/run.rs", + "rust/lithe-core/src/project/maven.rs", + "rust/lithe-core/src/tests/languages.rs", + ".agents/notes/implemented/feature/2026-09-25-windows-mvnd-toolchain.md", + "windows/tauri/src/i18n/locale.ts" + ], + "implementationStatus": "implemented", + "verificationStatus": "pending" + }, + "owner": "Java / Maven", + "verification": "在 Windows 选择 mvnd 根目录、bin 目录和启动文件,验证发现、版本、显式执行路径以及 JDT LS 全局配置;真实 mvnd 执行最小 Maven 构建。 自动入口仅显示“自动”,手动选择显示路径,未找到时提示选择安装目录。" + }, { "id": "java-maven-project", "area": "Java", diff --git a/windows/tauri/src-tauri/src/run.rs b/windows/tauri/src-tauri/src/run.rs index 5ed1a5806..006e820a9 100644 --- a/windows/tauri/src-tauri/src/run.rs +++ b/windows/tauri/src-tauri/src/run.rs @@ -21,6 +21,9 @@ use tauri::{AppHandle, Emitter, Manager}; mod launch_arguments; +const MAVEN_LAUNCHERS: &[&str] = &["mvn.cmd", "mvn.bat", "mvn.exe", "mvn"]; +const MVND_LAUNCHERS: &[&str] = &["mvnd.exe", "mvnd.cmd", "mvnd.bat", "mvnd"]; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; const RUN_OUTPUT_FLUSH_INTERVAL: Duration = Duration::from_millis(100); const RUN_OUTPUT_HIGH_WATER_BYTES: usize = 1_048_576; @@ -1218,7 +1221,17 @@ fn maven_executable_candidates(project_root: Option<&Path>) -> Vec { executables.push(home.join("bin").join("mvn.cmd")); executables.push(home.join("bin").join("mvn")); } - for name in ["mvn.cmd", "mvn.bat", "mvn.exe", "mvn"] { + // Preserve ordinary Maven discovery precedence; mvnd is an additional + // installation, not a reason to replace an existing project wrapper. + for name in MAVEN_LAUNCHERS { + if let Some(path) = lookup_on_path(name) { + executables.push(path); + } + } + if let Some(home) = std::env::var_os("MVND_HOME") { + executables.extend(custom_maven_executable_candidates(Path::new(&home))); + } + for name in MVND_LAUNCHERS { if let Some(path) = lookup_on_path(name) { executables.push(path); } @@ -1230,13 +1243,17 @@ fn custom_maven_executable_candidates(path: &Path) -> Vec { if path.is_file() { return vec![path.to_path_buf()]; } - let bin = path.join("bin"); - let mut candidates = ["mvn.cmd", "mvn.bat", "mvn.exe", "mvn"] + // Settings accept an installation root, its bin directory, or a launcher. + // Never substitute mvnd's embedded mvn: that silently disables the daemon. + [path.join("bin"), path.to_path_buf()] .into_iter() - .map(|name| bin.join(name)) - .collect::>(); - candidates.push(path.to_path_buf()); - candidates + .flat_map(|bin| { + MVND_LAUNCHERS + .iter() + .chain(MAVEN_LAUNCHERS) + .map(move |name| bin.join(name)) + }) + .collect() } pub(crate) fn probe_java_home(home: &Path) -> Option { @@ -1507,14 +1524,10 @@ fn resolve_maven_executable( } else { root.join(configured) }; - let candidates = [ - path.clone(), - path.join("bin").join("mvn.cmd"), - path.join("bin").join("mvn.bat"), - path.join("bin").join("mvn.exe"), - path.join("bin").join("mvn"), - ]; - if let Some(found) = candidates.into_iter().find(|candidate| candidate.is_file()) { + if let Some(found) = custom_maven_executable_candidates(&path) + .into_iter() + .find(|candidate| candidate.is_file()) + { return Ok(normalize_path(&found).to_string_lossy().into_owned()); } return Err("Maven executable path does not exist.".into()); @@ -1835,8 +1848,13 @@ fn java_version(output: &str) -> Option { fn maven_version(output: &str) -> Option { output.lines().find_map(|line| { - let rest = line.trim().strip_prefix("Apache Maven")?; - rest.split_whitespace().next().map(str::to_string) + // mvnd prints "Apache Maven Daemon ..." before the embedded Maven + // version. Do not report "Daemon" as the Maven version. + let rest = line.trim().strip_prefix("Apache Maven ")?; + let version = rest.split_whitespace().next()?; + version + .starts_with(|c: char| c.is_ascii_digit()) + .then(|| version.to_string()) }) } @@ -2314,6 +2332,92 @@ mod tests { fs::remove_dir_all(root).ok(); } + #[test] + fn mvnd_paths_resolve_consistently_for_import_discovery_and_execution() { + struct Fixture(PathBuf); + impl Drop for Fixture { + fn drop(&mut self) { + if let Err(error) = fs::remove_dir_all(&self.0) { + eprintln!("Could not clean mvnd fixture: {error}"); + } + } + } + let fixture = Fixture(temp_project()); + for name in ["mvnd.exe", "mvnd.cmd", "mvnd.bat", "mvnd"] { + let home = fixture.0.join(format!("maven daemon {name}")); + let launcher = home.join("bin").join(name); + fs::create_dir_all(launcher.parent().unwrap()).unwrap(); + fs::write(&launcher, "fixture; never executed").unwrap(); + // A distribution also contains ordinary Maven. Selecting mvnd must + // retain its daemon client, rather than silently selecting mvn. + fs::create_dir_all(home.join("mvn/bin")).unwrap(); + fs::write(home.join("mvn/bin/mvn.cmd"), "embedded Maven").unwrap(); + fs::write(fixture.0.join("mvnw.cmd"), "wrapper").unwrap(); + let expected = normalize_path(&launcher).to_string_lossy().into_owned(); + for configured in [home.clone(), home.join("bin"), launcher.clone()] { + let configured = configured.to_string_lossy(); + assert_eq!( + maven_executable_without_probing(&fixture.0, Some(&configured)), + Some(expected.clone()) + ); + assert_eq!( + resolve_maven_executable(&fixture.0, &fixture.0, &configured).unwrap(), + expected + ); + let discovered = + discover_maven_candidates(vec![launcher.clone()], Some(&configured), |path| { + path.is_file().then(|| MavenRuntime { + executable_path: normalize_path(path).to_string_lossy().into_owned(), + version: "3.9.16".into(), + }) + }); + assert_eq!(discovered.len(), 1); + assert_eq!(discovered[0].executable_path, expected); + } + } + let invalid = fixture + .0 + .join("missing-mvnd") + .to_string_lossy() + .into_owned(); + assert!(maven_executable_without_probing(&fixture.0, Some(&invalid)).is_none()); + assert!(resolve_maven_executable(&fixture.0, &fixture.0, &invalid).is_err()); + } + + #[test] + fn mvnd_version_uses_embedded_maven_instead_of_daemon_banner() { + let output = "Apache Maven Daemon (mvnd) 1.0.6 windows-amd64 native client\nTerminal: DumbTerminal\nApache Maven 3.9.16 (revision)\nMaven home: example/mvnd/mvn"; + assert_eq!(maven_version(output).as_deref(), Some("3.9.16")); + assert_eq!( + maven_version("Apache Maven 3.9.9 (revision)").as_deref(), + Some("3.9.9") + ); + assert_eq!(maven_version("Apache Maven Daemon (mvnd) 1.0.6"), None); + } + + #[test] + #[ignore = "integration: requires LITHE_TEST_MVND_HOME pointing to a real distribution"] + fn mvnd_installed_distribution_resolves_without_spawning_a_daemon() { + let home = PathBuf::from( + std::env::var_os("LITHE_TEST_MVND_HOME").expect("set LITHE_TEST_MVND_HOME"), + ); + let expected = normalize_path(&home.join("bin/mvnd.exe")) + .to_string_lossy() + .into_owned(); + assert!(Path::new(&expected).is_file()); + for configured in [home.clone(), home.join("bin"), home.join("bin/mvnd.exe")] { + let configured = configured.to_string_lossy(); + assert_eq!( + maven_executable_without_probing(&home, Some(&configured)), + Some(expected.clone()) + ); + assert_eq!( + resolve_maven_executable(&home, &home, &configured).unwrap(), + expected + ); + } + } + #[test] fn custom_maven_home_discovers_its_bin_executable() { struct Fixture(PathBuf); diff --git a/windows/tauri/src/i18n/locale.ts b/windows/tauri/src/i18n/locale.ts index 2281a4fce..9e99ddaeb 100644 --- a/windows/tauri/src/i18n/locale.ts +++ b/windows/tauri/src/i18n/locale.ts @@ -1556,9 +1556,9 @@ const catalogs = { "run.mainClass": "Main class", "run.jdkHome": "JDK Home", "run.jdkHomeHint": "Leave empty to use the detected JDK.", - "run.mavenExecutable": "Maven home or executable", + "run.mavenExecutable": "Maven / mvnd home or executable", "run.mavenExecutableHint": - "Choose a Maven home directory, or leave empty to use the project wrapper or detected Maven.", + "Choose a Maven or mvnd home/bin directory or executable; leave empty to use the project wrapper or a detected installation.", "run.nodeExecutable": "Node.js executable", "run.nodeExecutableHint": "Choose node.exe, or leave empty to use the detected Node.js runtime.", @@ -1625,7 +1625,7 @@ const catalogs = { "maven.settings": "Maven Settings", "maven.automatic": "Automatic", "maven.localRepository": "Local repository", - "maven.mavenExecutable": "Maven home or executable", + "maven.mavenExecutable": "Maven / mvnd home or executable", "maven.javaHome": "Maven JDK Home", "maven.stop": "Stop Maven task", "maven.rerunTest": "Rerun test", @@ -1773,7 +1773,7 @@ const catalogs = { "settings.run.description": "Select a service or task to configure its arguments, environment and project-environment overrides. Changes apply after Save.", "settings.project.openProject": "Open a project to configure its JDK and Maven.", "settings.project.scope": "Defaults for this project on this computer. Run configurations inherit these values unless explicitly overridden. Leave a path empty to use automatic selection.", - "settings.project.mavenAutomatic": "Automatic: project Maven Wrapper, then detected Maven. You can also enter a Maven home or executable path.", + "settings.project.mavenAutomatic": "Automatic", "settings.project.useProjectJdk": "Use project JDK", "settings.project.detected": "Detected installations", "settings.project.saved": "Project environment saved.", @@ -1796,7 +1796,7 @@ const catalogs = { "toolchain.maven": "Maven {version}", "toolchain.detecting": "Detecting…", "toolchain.javaNotFound": "No JDK found. Set JAVA_HOME, install a JDK, or choose a JDK directory.", - "toolchain.mavenNotFound": "No Maven found. Add a Maven Wrapper to the project or choose a Maven installation.", + "toolchain.mavenNotFound": "No Maven or mvnd found. Enter or choose its installation directory, or add a Maven Wrapper to the project.", "toolchain.invalid": "Cannot be used: {message}", "toolchain.failed": "Could not resolve: {message}", "settings.tabs.appearance": "Appearance", @@ -5969,8 +5969,8 @@ const catalogs = { "run.mainClass": "主类", "run.jdkHome": "JDK 主目录", "run.jdkHomeHint": "留空则使用自动检测到的 JDK。", - "run.mavenExecutable": "Maven 主目录 / 可执行文件", - "run.mavenExecutableHint": "可选择 Maven 主目录;留空则使用项目 Wrapper 或系统 Maven。", + "run.mavenExecutable": "Maven / mvnd 主目录或可执行文件", + "run.mavenExecutableHint": "可选择 Maven 或 mvnd 主目录、bin 目录或可执行文件;留空则使用项目 Wrapper 或已检测到的安装。", "run.nodeExecutable": "Node.js 可执行文件", "run.nodeExecutableHint": "可选择 node.exe;留空则使用自动检测到的 Node.js 运行时。", "run.mavenJdkHome": "Maven JDK 主目录", @@ -6034,7 +6034,7 @@ const catalogs = { "maven.settings": "Maven 设置", "maven.automatic": "自动检测", "maven.localRepository": "本地仓库", - "maven.mavenExecutable": "Maven 主目录 / 可执行文件", + "maven.mavenExecutable": "Maven / mvnd 主目录或可执行文件", "maven.javaHome": "Maven JDK 主目录", "maven.stop": "停止 Maven 任务", "maven.rerunTest": "重新运行测试", @@ -6175,7 +6175,7 @@ const catalogs = { "settings.run.description": "选择服务或任务,配置启动参数、环境变量和项目环境覆盖项;点击保存后生效。", "settings.project.openProject": "打开项目后,可在这里配置项目 JDK 和 Maven。", "settings.project.scope": "仅保存在当前电脑,作用于当前项目。运行配置默认继承这些值,单独设置的覆盖值保持不变。路径留空时使用自动选择。", - "settings.project.mavenAutomatic": "自动:优先使用项目 Maven Wrapper,其次使用检测到的 Maven。也可以填写 Maven 主目录或可执行文件路径。", + "settings.project.mavenAutomatic": "自动", "settings.project.useProjectJdk": "使用项目 JDK", "settings.project.detected": "检测到的安装", "settings.project.saved": "项目环境已保存。", @@ -6198,7 +6198,7 @@ const catalogs = { "toolchain.maven": "Maven {version}", "toolchain.detecting": "正在检测…", "toolchain.javaNotFound": "未找到 JDK。请设置 JAVA_HOME、安装 JDK,或选择 JDK 目录。", - "toolchain.mavenNotFound": "未找到 Maven。请为项目添加 Maven Wrapper,或选择 Maven 安装目录。", + "toolchain.mavenNotFound": "未找到 Maven 或 mvnd。请手动填写或选择安装目录,或为项目添加 Maven Wrapper。", "toolchain.invalid": "无法使用:{message}", "toolchain.failed": "无法解析:{message}", "settings.tabs.appearance": "外观", From ca3ae127c237900542471030d270e8743cc9604c Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Fri, 16 Oct 2026 11:54:33 +0800 Subject: [PATCH 2/2] feat(java): support mvnd on Windows and macOS Resolve explicit mvnd installations and automatic candidates consistently for execution and Maven import. Read embedded Maven settings and versions, support macOS aliases and cache invalidation, and cover both platforms with regression fixtures and tests. Refs #889. --- ...aven-settings-reach-the-language-server.md | 76 +++- .../zh-Hans.lproj/Localizable.strings | 4 +- .../MacOS/Runtime/MacRuntimeDiscovery.swift | 80 +++-- .../App/ProjectRuntimeSettingsView.swift | 8 +- .../LitheTests/MacRuntimeDiscoveryTests.swift | 59 ++++ .../MacRuntimeProbeCacheTests.swift | 23 ++ .../Tests/LitheTests/MavenRuntimeTests.swift | 19 +- rust/lithe-core/src/project/maven.rs | 96 +++++- rust/lithe-core/src/tests/languages.rs | 168 +++++++++ .../execution/maven-runtime-versions.json | 10 + .../features/java-runtime-discovery.json | 20 +- windows/tauri/src-tauri/src/run.rs | 325 ++++++++++++++++-- .../run/utils/run-configuration.test.ts | 42 +++ .../features/run/utils/run-configuration.ts | 5 +- windows/tauri/src/i18n/locale.ts | 8 +- 15 files changed, 842 insertions(+), 101 deletions(-) create mode 100644 shared/fixtures/execution/maven-runtime-versions.json diff --git a/.agents/notes/implemented/architecture/2026-09-21-maven-settings-reach-the-language-server.md b/.agents/notes/implemented/architecture/2026-09-21-maven-settings-reach-the-language-server.md index 58d4c2c4c..48f5c44b6 100644 --- a/.agents/notes/implemented/architecture/2026-09-21-maven-settings-reach-the-language-server.md +++ b/.agents/notes/implemented/architecture/2026-09-21-maven-settings-reach-the-language-server.md @@ -48,13 +48,25 @@ Maven 自己的设置解析顺序是“用户级 `~/.m2/settings.xml` 叠加安 ## 决策 -**1. 安装级设置转成 `globalSettings`。** Core 从配置的 Maven 主目录或 -`/bin/mvn*` 启动器推出 `conf/settings.xml`,作为 -`java.configuration.maven.globalSettings` 交给 JDT LS。 - -推导失败时返回空,这是刻意的:项目 Wrapper(`mvnw`)不在任何安装目录里, -没有安装级设置;此时 JDT LS 停留在自身默认,而这正是直接敲 `mvnw` 会得到的 -结果。两边仍然一致。 +**1. 安装级设置转成 `globalSettings`。** Core 接受 Maven 安装根目录、`bin` +目录或已存在的 `mvn` / `mvnd` 启动器,根据实际选中的客户端推导全局设置,作为 +`java.configuration.maven.globalSettings` 交给 JDT LS。普通 Maven 使用 +`/conf/settings.xml`;Maven Daemon(mvnd,复用后台 JVM 的 Maven 客户端) +使用 `/mvn/conf/settings.xml`,不能把 daemon 自己的 `conf` 当成 Maven 配置。 + +目录必须含有可识别的客户端,仅存在 `conf/settings.xml` 不构成 Maven 安装。 +不存在的启动器、任意程序、Wrapper(包括放在 `bin` 下的 `mvnw`)和缺失的设置 +均返回空;选中 mvnd 后缺少内嵌设置时,也不能回退到旁边普通 Maven 的设置。 +这保留了 JDT 的默认设置行为,同时避免把无关文件送进语言服务。 + +显式目录里同时有两种客户端时优先 mvnd;显式指定 `bin/mvn` 仍保留普通 Maven +语义。比如选 `/tools/mvnd/bin/mvnd` 应读取 `/tools/mvnd/mvn/conf/settings.xml`, +不能悄悄改为执行 `/tools/mvnd/mvn/bin/mvn`,也不能读取 `/tools/mvnd/conf/settings.xml`。 +Core 只读文件系统,不探测版本、不启动 daemon,也不接管 JDT 内置 m2e 的项目导入。 + +为兼容 Homebrew 等安装器,直接选中的目录或客户端若是符号链接,会跟随到真实 +安装后定位设置;失效链接返回空。普通路径不统一做 canonicalize,避免 macOS +`/var` 被无条件改写为 `/private/var`,影响已有上下文的路径一致性。 **2. 设置随 `initialize` 一起送达。** `adapt_initialization_options()` 把 `settings.java` 注入 `initializationOptions`,赶在 m2e 配置和首次导入之前。 @@ -72,6 +84,17 @@ Maven 设置面板的显式配置 这样在任意一处填 `D:\apache-maven-3.9.16`,语言服务和命令行都会用它。 +两平台的自动选择保留可用 Wrapper、普通 Maven、mvnd 的顺序,新增 mvnd 不能 +抢走已有 Maven 安装。普通 Maven 候选保持宿主原有顺序;mvnd 从 `MVND_HOME` +和 PATH 补充,macOS 同时考虑 Homebrew 的标准位置。显式选择安装根目录、`bin` +或启动文件则优先服从用户选择,Windows 前端的候选匹配也必须接受相同形式。 + +工具链探测展示的是内嵌 **Apache Maven** 版本:`Apache Maven Daemon (mvnd)` +标题不构成版本结果,只读后面的数字开头 `Apache Maven` 版本,保留预发行后缀; +只有 daemon 标题时判为未获得 Maven 版本。两端使用 +`shared/fixtures/execution/maven-runtime-versions.json` 固定这些例子。macOS 探测 +缓存还观察内嵌 Maven 库,避免 mvnd 启动器未变但 Maven 更新后继续显示旧版本。 + **这条链上不允许启动进程。** 它跑在 Maven 工程加载里,而 Java 编辑器打开文件 时会先 `await` 这个加载再启动语言服务(`resolve-editor-lsp-launch.ts`)。 现成的 `run_discover_toolchains` 会对每个候选跑 `mvn -version` / `java -version`, @@ -115,6 +138,12 @@ Maven 设置面板的显式配置 - **把两个「Maven 主目录」输入框合并成一个。** 被否。运行工具链和项目导入的 作用域确实不同,合并会让“只想给某个运行配置换 JDK”的场景没法表达。改为共用 一条解析链,保留两个入口。 +- **先完整拆出 Maven 插件,再支持 mvnd。** 延后插件化。mvnd 是既有 Maven + 工具链的另一种启动器,复用宿主发现、执行和 Core 设置推导即可。完整插件化还 + 涉及运行配置、依赖树和 JDT 配置桥接;把它作为前提会扩大改动面。以后迁移归属 + 时连同这些选择规则和回归测试迁移,不能借插件化改变命令行与语言服务的一致性。 +- **选中 mvnd 后改跑内嵌 mvn,或使用 daemon 的 conf 兜底。** 被否。前者失去用户 + 选择 daemon 的意义,后者使 JDT 与实际构建使用不同配置;缺失设置应明确返回空。 - **Core 直接读 `.lithe/run/local.json` 拿运行工具链。** 被否。运行配置的本地 层由宿主读取后传入 Core(`local_layer_document`),Core 自己读盘会破坏既有的 持久化归属。解析链因此留在平台侧。 @@ -375,8 +404,39 @@ profile 变化、失败或超时期间变更补跑、握手前无设置变化的 ## 验证 +mvnd 的 Core 定向验证应包含整个 `tests::languages::`:除 mvnd 根目录、`bin`、 +四种启动文件、同目录普通 Maven、缺失内嵌设置与符号链接外,还覆盖普通 Maven +目录必须含有启动器、Wrapper 拒绝以及原有 Maven/JDT 设置回归。Apple Silicon +宿主可复用 macOS Core 构建目录运行以下单测试计时命令;其他宿主应使用自己的 +原生 target,不能把 macOS 结果当作 Windows 宿主验证。计时器生成 HTML、JUnit、 +JSON 和日志,报告目录独立以免覆盖并行 Swift 验证的首页。 + +```bash +CARGO_TARGET_DIR="$PWD/rust/target/macos" CARGO_BUILD_JOBS=2 node --input-type=module <<'NODE' +import { run, parseArguments } from './.agents/skills/write-stable-tests/scripts/run-rust-tests-with-timing.mjs'; +import { runProcess } from './.agents/skills/write-stable-tests/scripts/test-timing-lib.mjs'; +await run(parseArguments([ + '--manifest', 'rust/lithe-core/Cargo.toml', '--package', 'lithe-core', '--keep-going', + '--report', '.artifacts/test-stability/core-mvnd/core-mvnd-languages.json', +]), { runProcessImpl: request => runProcess({ + ...request, + args: request.command === 'cargo' + ? [...request.args, '--target', 'aarch64-apple-darwin', '--jobs', '2', '--lib'] + : request.args.includes('--list') ? [...request.args, 'tests::languages::'] : request.args, +}) }); +NODE +``` + +宿主验收还需在各自原生系统覆盖:仅 `MVND_HOME`、仅 PATH、与普通 Maven 共存、 +有效 Wrapper 优先、无效 `MVND_HOME` 回退、三种显式选择形式,以及版本共享 fixture。 +macOS 使用 `MacRuntimeDiscoveryTests`、`MacRuntimeProbeCacheTests` 和 +`MavenRuntimeTests`;Windows 宿主使用 `run` 中的 Maven 发现与解析测试,前端 +使用 `run-configuration.test.ts`。真实 mvnd 验收须用独立临时项目、本地仓库和 +本次拥有的 daemon 状态目录,验证构建与 JDT 全局设置,并在有界结束后清理进程。 +单元测试或跨平台编译不能替代这一步,功能矩阵在原生验收完成前保持待验证。 + - Rust Core:`cargo test --manifest-path rust/lithe-core/Cargo.toml` - 覆盖主目录与启动器两种写法都能推出 `conf/settings.xml`、Wrapper 与无 `conf` + 覆盖主目录、bin 目录与启动器写法都能推出正确的全局设置、Wrapper 与无 `conf` 的安装正确返回空、`initializationOptions` 注入且不吃掉扩展目录的键、 Profile 指纹随安装变化,以及生成文档保留镜像/注释、插入缺失节点、展开空元素、 转义路径中的 `&`、不误伤 profile 内的同名元素。完整校验运行 diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index e1e87ab69..4d3db9be8 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -248,7 +248,7 @@ "Maven Home" = "Maven 主目录"; "Maven JDK" = "Maven JDK"; "Detected Maven" = "检测到的 Maven"; -"Automatic uses a project mvnw first, then the system Maven on PATH." = "自动模式会优先使用项目 mvnw,然后使用 PATH 中的系统 Maven。"; +"Automatic uses a project mvnw first, then detected Maven, then mvnd." = "自动模式会优先使用项目 mvnw,其次使用检测到的 Maven,最后使用 mvnd。"; "Project runtime settings are saved locally for this project." = "项目运行时设置会保存在本机的项目配置中。"; "Open a project to configure its JDK and Maven runtime." = "打开项目后即可配置 JDK 和 Maven 运行时。"; "Application-wide editor and terminal settings remain available in the other categories." = "应用级编辑器和终端设置仍可在其他分类中使用。"; @@ -259,7 +259,7 @@ "Maven Wrapper is not executable" = "Maven Wrapper 不可执行"; "Maven executable not resolved" = "未解析出 Maven 可执行文件"; "Choose a JDK directory" = "选择 JDK 目录"; -"Choose Maven home or bin/mvn" = "选择 Maven 主目录或 bin/mvn"; +"Choose Maven or mvnd home, bin, or executable" = "选择 Maven 或 mvnd 的主目录、bin 目录或可执行文件"; "Detected JDK" = "检测到的 JDK"; "Inherit project JDK" = "继承项目 JDK"; "Use Project JDK" = "使用项目 JDK"; diff --git a/macos/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeDiscovery.swift b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeDiscovery.swift index b8e76b5f6..8891731ca 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeDiscovery.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeDiscovery.swift @@ -26,18 +26,16 @@ enum MacRuntimeDiscovery { guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory) else { return nil } - let executable = isDirectory.boolValue - ? url.appendingPathComponent("bin/mvn") - : url - var executableIsDirectory: ObjCBool = false - guard FileManager.default.fileExists( - atPath: executable.path, - isDirectory: &executableIsDirectory - ), !executableIsDirectory.boolValue, - FileManager.default.isExecutableFile(atPath: executable.path) else { - return nil + let candidates: [URL] + if isDirectory.boolValue { + // A daemon distribution must launch its client, not its embedded Maven. + candidates = [url.appendingPathComponent("bin"), url].flatMap { bin in + ["mvnd", "mvn"].map { bin.appendingPathComponent($0) } + } + } else { + candidates = [url] } - return executable.standardizedFileURL + return candidates.first { MacRuntimeLocator().isExecutable(at: $0) } } static func validJavaHome(_ path: String) -> URL? { @@ -133,21 +131,27 @@ enum MacRuntimeDiscovery { return paths.values.sorted { $0.path < $1.path } } - private static func discoverMavenExecutables(environment: [String: String]) -> [URL] { - var paths = Set() - func add(_ path: String?) { - guard let path else { return } + static func discoverMavenExecutables( + environment: [String: String], + isExecutable: (URL) -> Bool = { MacRuntimeLocator().isExecutable(at: $0) } + ) -> [URL] { + var paths: [URL] = [] + var seen = Set() + func add(_ path: String) { let expanded = (path as NSString).expandingTildeInPath let url = URL(fileURLWithPath: expanded).standardizedFileURL - guard FileManager.default.isExecutableFile(atPath: url.path) else { return } - paths.insert(url.path) + guard isExecutable(url), seen.insert(url.path).inserted else { return } + paths.append(url) } - if let mavenHome = environment["MAVEN_HOME"] { - add(URL(fileURLWithPath: mavenHome).appendingPathComponent("bin/mvn").path) + for key in ["MAVEN_HOME", "M2_HOME"] { + if let home = environment[key], !home.isEmpty { + add(URL(fileURLWithPath: home).appendingPathComponent("bin/mvn").path) + } } - for component in (environment["PATH"] ?? "").split(separator: ":").map(String.init) { - add(URL(fileURLWithPath: component).appendingPathComponent("mvn").path) + let pathDirectories = (environment["PATH"] ?? "").split(separator: ":").map(String.init) + for directory in pathDirectories { + add(URL(fileURLWithPath: directory).appendingPathComponent("mvn").path) } for path in [ "/opt/homebrew/opt/maven/bin/mvn", @@ -158,7 +162,23 @@ enum MacRuntimeDiscovery { ] { add(path) } - return paths.sorted().map(URL.init(fileURLWithPath:)) + // Preserve ordinary Maven preference even when mvnd appears earlier on PATH. + // Discovery only inspects files; importing Java must not start a daemon. + if let home = environment["MVND_HOME"], !home.isEmpty { + add(URL(fileURLWithPath: home).appendingPathComponent("bin/mvnd").path) + } + for directory in pathDirectories { + add(URL(fileURLWithPath: directory).appendingPathComponent("mvnd").path) + } + for path in [ + "/opt/homebrew/opt/mvnd/bin/mvnd", + "/opt/homebrew/bin/mvnd", + "/usr/local/opt/mvnd/bin/mvnd", + "/usr/local/bin/mvnd" + ] { + add(path) + } + return paths } static func probeJavaHome(_ home: URL) -> JavaRuntimeCandidate? { @@ -180,7 +200,9 @@ enum MacRuntimeDiscovery { static func probeMaven(_ executable: URL) -> MavenRuntimeCandidate? { let resolvedExecutable = executable.resolvingSymlinksInPath() - let homeURL = resolvedExecutable.deletingLastPathComponent().deletingLastPathComponent() + let distribution = resolvedExecutable.deletingLastPathComponent().deletingLastPathComponent() + let homeURL = resolvedExecutable.lastPathComponent == "mvnd" + ? distribution.appendingPathComponent("mvn") : distribution let library = homeURL.appendingPathComponent("lib") let libraries = directoryNames(at: library.path).filter { $0.hasPrefix("maven-core-") }.sorted() let wrapper = resolvedExecutable.deletingLastPathComponent().appendingPathComponent(".mvn/wrapper") @@ -188,11 +210,15 @@ enum MacRuntimeDiscovery { executable: executable, arguments: ["-version"], dependencies: [library] + libraries.map { library.appendingPathComponent($0) } + [wrapper.appendingPathComponent("maven-wrapper.properties"), wrapper.appendingPathComponent("maven-wrapper.jar")], - accepts: { firstCapture(pattern: #"Apache Maven\s+([^\s]+)"#, in: $0) != nil } + accepts: { mavenVersion(in: $0) != nil } ) - let version = firstCapture(pattern: #"Apache Maven\s+([^\s]+)"#, in: output) ?? "" - let home = executable.deletingLastPathComponent().deletingLastPathComponent().path - return MavenRuntimeCandidate(homePath: home, executablePath: executable.path, version: version) + guard let version = mavenVersion(in: output) else { return nil } + return MavenRuntimeCandidate(homePath: distribution.path, executablePath: executable.path, version: version) + } + + static func mavenVersion(in output: String) -> String? { + // mvnd prints its own banner before the embedded Apache Maven version. + firstCapture(pattern: #"Apache Maven\s+([0-9][^\s\x1B]*)"#, in: output) } private static func javaHomeOutput() -> [String] { diff --git a/macos/Sources/Lithe/Views/App/ProjectRuntimeSettingsView.swift b/macos/Sources/Lithe/Views/App/ProjectRuntimeSettingsView.swift index c6faf99e4..089fc9d93 100644 --- a/macos/Sources/Lithe/Views/App/ProjectRuntimeSettingsView.swift +++ b/macos/Sources/Lithe/Views/App/ProjectRuntimeSettingsView.swift @@ -207,12 +207,12 @@ struct ProjectRuntimeSettingsView: View { pathField( title: "Maven Home", value: mavenHomePathBinding, - placeholder: "Choose Maven home or bin/mvn", - chooseTitle: "Choose Maven home or bin/mvn", + placeholder: "Choose Maven or mvnd home, bin, or executable", + chooseTitle: "Choose Maven or mvnd home, bin, or executable", fileOrDirectory: true ) } - Text("Automatic uses a project mvnw first, then the system Maven on PATH.") + Text("Automatic uses a project mvnw first, then detected Maven, then mvnd.") .font(LitheTheme.smallFont) .foregroundStyle(LitheTheme.secondaryText) if let workspaceURL = model.workspaceURL { @@ -311,7 +311,7 @@ struct ProjectRuntimeSettingsView: View { title: "Maven Home", value: moduleMavenHomeBinding(for: subproject), placeholder: "Inherit project Maven", - chooseTitle: "Choose Maven home or bin/mvn", + chooseTitle: "Choose Maven or mvnd home, bin, or executable", fileOrDirectory: true ) row("Maven JDK") { diff --git a/macos/Tests/LitheTests/MacRuntimeDiscoveryTests.swift b/macos/Tests/LitheTests/MacRuntimeDiscoveryTests.swift index 0e96f6594..3f4d0345b 100644 --- a/macos/Tests/LitheTests/MacRuntimeDiscoveryTests.swift +++ b/macos/Tests/LitheTests/MacRuntimeDiscoveryTests.swift @@ -65,6 +65,65 @@ struct MacRuntimeDiscoveryTests { #expect(homes == [javaHome.standardizedFileURL]) } + @Test + func discoversMvndWithoutProbingAndPreservesMavenPriority() { + func discover(_ environment: [String: String], files: Set) -> [String] { + MacRuntimeDiscovery.discoverMavenExecutables( + environment: environment, isExecutable: { files.contains($0.path) } + ).map(\.path) + } + let daemon = "/fixture/daemon/bin/mvnd" + let pathDaemon = "/fixture/path/mvnd" + let maven = "/fixture/maven/bin/mvn" + #expect(discover(["MVND_HOME": "/fixture/daemon"], files: [daemon]) == [daemon]) + #expect(discover(["PATH": "/fixture/path"], files: [pathDaemon]) == [pathDaemon]) + #expect(discover(["MVND_HOME": "/missing", "PATH": "/fixture/path"], files: [pathDaemon]) == [pathDaemon]) + #expect(discover( + ["MAVEN_HOME": "/fixture/maven", "MVND_HOME": "/fixture/daemon", "PATH": "/fixture/path"], + files: [daemon, pathDaemon, maven] + ) == [maven, daemon, pathDaemon]) + #expect(discover( + ["PATH": "/fixture/path:/fixture/maven/bin:/fixture/path"], files: [pathDaemon, maven] + ) == [maven, pathDaemon]) + #expect(discover(["M2_HOME": "/fixture/maven"], files: [maven]) == [maven]) + #expect(discover(["MVND_HOME": "/missing"], files: []) == []) + #expect(discover([:], files: ["/opt/homebrew/bin/mvnd"]) == ["/opt/homebrew/bin/mvnd"]) + #expect(discover([:], files: ["/usr/local/bin/mvn", "/opt/homebrew/bin/mvnd"]) + == ["/usr/local/bin/mvn", "/opt/homebrew/bin/mvnd"]) + } + + @Test + func explicitDaemonHomeNeverFallsBackToEmbeddedMaven() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let embedded = root.appendingPathComponent("mvn/bin/mvn") + try FileManager.default.createDirectory(at: embedded.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data().write(to: embedded) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: embedded.path) + #expect(MacRuntimeDiscovery.mavenExecutable(forHomePath: root.path) == nil) + let bin = root.appendingPathComponent("bin") + try FileManager.default.createDirectory(at: bin, withIntermediateDirectories: true) + // Traversable directories named like launchers are not executable files. + try FileManager.default.createDirectory(at: bin.appendingPathComponent("mvnd"), withIntermediateDirectories: true) + #expect(MacRuntimeDiscovery.mavenExecutable(forHomePath: root.path) == nil) + } + + @Test + func mavenVersionsMatchSharedContract() throws { + struct Fixture: Decodable { + struct Case: Decodable { let output: String; let version: String? } + let cases: [Case] + } + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent().deletingLastPathComponent() + .deletingLastPathComponent().deletingLastPathComponent() + let fixture = try JSONDecoder().decode(Fixture.self, from: Data(contentsOf: + root.appendingPathComponent("shared/fixtures/execution/maven-runtime-versions.json"))) + for example in fixture.cases { + #expect(MacRuntimeDiscovery.mavenVersion(in: example.output) == example.version) + } + } + private func createExecutableJava(at home: URL) throws { let executable = home.appendingPathComponent("bin/java") try FileManager.default.createDirectory( diff --git a/macos/Tests/LitheTests/MacRuntimeProbeCacheTests.swift b/macos/Tests/LitheTests/MacRuntimeProbeCacheTests.swift index 1b3bc3977..e6b530de9 100644 --- a/macos/Tests/LitheTests/MacRuntimeProbeCacheTests.swift +++ b/macos/Tests/LitheTests/MacRuntimeProbeCacheTests.swift @@ -63,6 +63,29 @@ struct MacRuntimeProbeCacheTests { } } + @Test + func mvndProbeUsesEmbeddedMavenAndInvalidatesItsLibraries() async throws { + try await onThrowingWorker { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let home = root.appendingPathComponent("daemon") + let executable = home.appendingPathComponent("bin/mvnd") + let library = home.appendingPathComponent("mvn/lib/maven-core-fixture.jar") + let log = home.appendingPathComponent("probes") + try FileManager.default.createDirectory(at: executable.deletingLastPathComponent(), withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: library.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data("first".utf8).write(to: library) + try Data("#!/bin/sh\necho probe >> \"$(dirname \"$0\")/../probes\"\necho 'Apache Maven Daemon (mvnd) 1.0.6'\necho 'Apache Maven 3.9.11 (fixture)'\n".utf8).write(to: executable) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path) + #expect(MacRuntimeDiscovery.probeMaven(executable)?.version == "3.9.11") + #expect(MacRuntimeDiscovery.probeMaven(executable)?.version == "3.9.11") + #expect(try String(contentsOf: log, encoding: .utf8).split(separator: "\n").count == 1) + try Data("changed Maven library".utf8).write(to: library) + #expect(MacRuntimeDiscovery.probeMaven(executable)?.version == "3.9.11") + #expect(try String(contentsOf: log, encoding: .utf8).split(separator: "\n").count == 2) + } + } + @Test func mavenWrapperReusesVersionUntilDistributionChanges() async throws { try await onThrowingWorker { diff --git a/macos/Tests/LitheTests/MavenRuntimeTests.swift b/macos/Tests/LitheTests/MavenRuntimeTests.swift index 269dda7bd..4cb50e929 100644 --- a/macos/Tests/LitheTests/MavenRuntimeTests.swift +++ b/macos/Tests/LitheTests/MavenRuntimeTests.swift @@ -488,14 +488,14 @@ struct MavenRuntimeTests { #expect(service.mavenExecutable(at: root, overridePath: "toolchains/missing") == nil) } - @Test + @Test(arguments: ["mvn", "mvnd"]) @MainActor - func macRuntimeDiscoveryDistinguishesMavenHomeFromExecutable() throws { + func macRuntimeDiscoveryDistinguishesMavenHomeFromExecutable(launcher: String) throws { let testRoot = FileManager.default.temporaryDirectory .appendingPathComponent("lithe-maven-runtime-\(UUID().uuidString)", isDirectory: true) defer { try? FileManager.default.removeItem(at: testRoot) } let home = testRoot.appendingPathComponent("apache-maven", isDirectory: true) - let homeExecutable = home.appendingPathComponent("bin/mvn") + let homeExecutable = home.appendingPathComponent("bin/\(launcher)") let directExecutable = testRoot.appendingPathComponent("custom-mvn") let invalidHome = testRoot.appendingPathComponent("invalid-maven", isDirectory: true) try FileManager.default.createDirectory( @@ -529,12 +529,23 @@ struct MavenRuntimeTests { #expect(service.mavenExecutable(at: testRoot, overridePath: directExecutable.path) == directExecutable) #expect(service.mavenExecutable(at: testRoot, overridePath: invalidHome.path) == nil) + if launcher == "mvnd" { + let ordinaryMaven = home.appendingPathComponent("bin/mvn") + try Data().write(to: ordinaryMaven) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: ordinaryMaven.path) + #expect(MacRuntimeDiscovery.mavenExecutable(forHomePath: home.path) == homeExecutable) + try FileManager.default.setAttributes([.posixPermissions: 0o644], ofItemAtPath: ordinaryMaven.path) + } + let binLink = testRoot.appendingPathComponent("bin-alias", isDirectory: true) + try FileManager.default.createSymbolicLink(at: binLink, withDestinationURL: home.appendingPathComponent("bin")) + #expect(service.mavenExecutable(at: testRoot, overridePath: binLink.path) == binLink.appendingPathComponent(launcher)) let homeLink = testRoot.appendingPathComponent("maven-link", isDirectory: true) let executableLink = testRoot.appendingPathComponent("mvn-link") try FileManager.default.createSymbolicLink(at: homeLink, withDestinationURL: home) try FileManager.default.createSymbolicLink(at: executableLink, withDestinationURL: homeExecutable) #expect(!locator.isExecutable(at: homeLink)) - #expect(service.mavenExecutable(at: testRoot, overridePath: homeLink.path) == homeLink.appendingPathComponent("bin/mvn")) + #expect(service.mavenExecutable(at: testRoot, overridePath: homeLink.path) == homeLink.appendingPathComponent("bin/\(launcher)")) + #expect(service.mavenExecutable(at: testRoot, overridePath: home.appendingPathComponent("bin").path) == homeExecutable) #expect(service.mavenExecutable(at: testRoot, overridePath: executableLink.path) == executableLink) try FileManager.default.setAttributes([.posixPermissions: 0o644], ofItemAtPath: homeExecutable.path) diff --git a/rust/lithe-core/src/project/maven.rs b/rust/lithe-core/src/project/maven.rs index e5ba6bae3..aacf3313b 100644 --- a/rust/lithe-core/src/project/maven.rs +++ b/rust/lithe-core/src/project/maven.rs @@ -455,28 +455,90 @@ fn settings_parse_error(error: quick_xml::Error) -> CoreError { .with_details(error.to_string()) } -/// Resolves the `conf/settings.xml` of the configured Maven installation. +/// Resolves the global settings read by the selected Maven or mvnd launcher. /// -/// The stored value may be either a Maven home or a launcher inside its `bin` -/// directory, because the platform settings accept both. A project wrapper such -/// as `mvnw` sits outside an installation and therefore resolves to `None`, -/// which matches the settings Maven itself would read when the wrapper runs. -/// Returning `None` also covers installations that ship no global settings, so -/// callers keep JDT LS on its own defaults instead of pointing it at a missing -/// file. +/// Accepts an installation home, its `bin` directory, or a recognized launcher. +/// Explicit directories prefer mvnd when both clients are installed; automatic +/// discovery precedence belongs to the host. Wrappers, missing clients and +/// installations without global settings return `None` without running a probe. fn installation_settings_path(maven_executable_path: Option<&str>) -> Option { let configured = Path::new(maven_executable_path?); - let home = if configured.is_dir() { - configured.to_path_buf() - } else { - // Only the `/bin/mvn*` layout identifies an installation root. - let bin = configured.parent()?; - if !bin.file_name()?.to_str()?.eq_ignore_ascii_case("bin") { - return None; + let maven_launchers = ["mvn.cmd", "mvn.bat", "mvn.exe", "mvn"]; + let mvnd_launchers = ["mvnd.exe", "mvnd.cmd", "mvnd.bat", "mvnd"]; + let is_launcher = |path: &Path| { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| { + mvnd_launchers + .iter() + .chain(maven_launchers.iter()) + .any(|launcher| name.eq_ignore_ascii_case(launcher)) + }) + }; + if !configured.is_dir() && (!configured.is_file() || !is_launcher(configured)) { + return None; + } + // Homebrew exposes client and directory aliases outside the installation. + // Only canonicalize an actual alias: canonicalizing every ordinary path + // would also rewrite macOS /var paths to /private/var in existing contexts. + let resolve_alias = |path: &Path| { + if path.symlink_metadata().ok()?.file_type().is_symlink() { + path.canonicalize().ok() + } else { + Some(path.to_path_buf()) } - bin.parent()?.to_path_buf() }; - let settings = home.join("conf").join("settings.xml"); + let configured = resolve_alias(configured)?; + let launcher = if configured.is_dir() { + [configured.join("bin"), configured.clone()] + .into_iter() + .flat_map(|bin| { + mvnd_launchers + .iter() + .chain(maven_launchers.iter()) + .map(move |name| bin.join(name)) + }) + .find(|candidate| candidate.is_file())? + } else { + configured + }; + let launcher = resolve_alias(&launcher)?; + // Hosts may return alias-to-bin/mvnd after resolving a selected bin directory. + // Resolve that parent alias as well, without rewriting ordinary /var paths. + let launcher = if launcher + .parent()? + .symlink_metadata() + .ok()? + .file_type() + .is_symlink() + { + launcher.canonicalize().ok()? + } else { + launcher + }; + if !is_launcher(&launcher) { + return None; + } + let bin = launcher.parent()?; + if !bin.file_name()?.to_str()?.eq_ignore_ascii_case("bin") { + return None; + } + let home = bin.parent()?; + let is_mvnd = mvnd_launchers.iter().any(|name| { + launcher + .file_name() + .and_then(|value| value.to_str()) + .is_some_and(|value| value.eq_ignore_ascii_case(name)) + }); + // The daemon's own conf is not Maven's conf. An explicitly selected mvn + // beside mvnd still keeps its own settings; missing embedded settings must + // never fall back to the daemon directory or another installation. + let maven_home = if is_mvnd { + home.join("mvn") + } else { + home.to_path_buf() + }; + let settings = maven_home.join("conf").join("settings.xml"); settings .is_file() .then(|| settings.to_string_lossy().into_owned()) diff --git a/rust/lithe-core/src/tests/languages.rs b/rust/lithe-core/src/tests/languages.rs index 81adaa230..ba7817b60 100644 --- a/rust/lithe-core/src/tests/languages.rs +++ b/rust/lithe-core/src/tests/languages.rs @@ -1519,6 +1519,7 @@ fn maven_installation_settings_reach_jdt_for_a_home_or_a_launcher() { for configured in [ maven_home.to_string_lossy().into_owned(), + maven_home.join("bin").to_string_lossy().into_owned(), launcher.to_string_lossy().into_owned(), ] { let configuration = jdt_configuration( @@ -1567,6 +1568,173 @@ fn maven_installation_settings_are_absent_for_wrappers_and_bare_installations() fs::remove_dir_all(root).expect("Maven fixture should be removable"); } +/// Removes this test's installation and symlinks even when an assertion fails. +struct MavenSettingsFixture { + root: PathBuf, +} + +impl MavenSettingsFixture { + fn new(label: &str) -> Self { + Self { + root: maven_reactor_root(label), + } + } + + fn settings_for(&self, configured: &Path) -> Option { + jdt_configuration( + self.root.to_str().expect("fixture path should be UTF-8"), + maven_jdt_context(Some(configured.to_string_lossy().into_owned())), + ) + .expect("Maven configuration should parse") + .global_settings_path + } +} + +impl Drop for MavenSettingsFixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.root); + } +} + +#[test] +fn mvnd_installation_settings_reach_jdt_for_every_client_home_and_bin() { + // Every platform launcher must read the embedded Maven configuration, never + // the daemon's unrelated conf directory, even when ordinary Maven coexists. + let fixture = MavenSettingsFixture::new("mvnd-jdt-settings"); + for name in ["mvnd.exe", "mvnd.cmd", "mvnd.bat", "mvnd"] { + let home = fixture + .root + .join(format!("installation with spaces-{name}")); + let bin = home.join("bin"); + let embedded_conf = home.join("mvn/conf"); + fs::create_dir_all(&bin).unwrap(); + fs::create_dir_all(&embedded_conf).unwrap(); + fs::create_dir_all(home.join("conf")).unwrap(); + let settings = embedded_conf.join("settings.xml"); + fs::write(&settings, "").unwrap(); + fs::write(home.join("conf/settings.xml"), "").unwrap(); + let launcher = bin.join(name); + fs::write(&launcher, "").unwrap(); + let ordinary_launcher = bin.join("mvn.cmd"); + fs::write(&ordinary_launcher, "").unwrap(); + for configured in [&home, &bin, &launcher] { + assert_eq!( + fixture.settings_for(configured), + Some(settings.to_string_lossy().into_owned()), + "embedded settings for {}", + configured.display() + ); + } + assert_eq!( + fixture.settings_for(&ordinary_launcher), + Some( + home.join("conf/settings.xml") + .to_string_lossy() + .into_owned() + ), + "an explicit ordinary Maven client retains its own settings" + ); + } +} + +#[test] +fn mvnd_missing_embedded_settings_never_fall_back_to_distribution_conf() { + // A missing embedded file must not silently select the daemon's conf or + // ordinary Maven beside it, whether the selected path is home, bin or client. + let fixture = MavenSettingsFixture::new("mvnd-jdt-missing-settings"); + let home = fixture.root.join("mvnd"); + let bin = home.join("bin"); + fs::create_dir_all(&bin).unwrap(); + fs::create_dir_all(home.join("conf")).unwrap(); + fs::write(home.join("conf/settings.xml"), "").unwrap(); + fs::write(bin.join("mvn"), "").unwrap(); + let launcher = bin.join("mvnd"); + fs::write(&launcher, "").unwrap(); + for configured in [&home, &bin, &launcher] { + assert_eq!( + fixture.settings_for(configured), + None, + "{}", + configured.display() + ); + } +} + +#[test] +fn maven_installation_settings_reject_missing_clients_and_wrappers_in_bin() { + // Nearby conf files do not make a nonexistent executable, arbitrary program + // or project wrapper a Maven installation. Even a wrapper under bin is not mvn. + let fixture = MavenSettingsFixture::new("maven-jdt-invalid-client"); + let home = fixture.root.join("installation"); + let bin = home.join("bin"); + fs::create_dir_all(&bin).unwrap(); + for conf in [home.join("conf"), home.join("mvn/conf")] { + fs::create_dir_all(&conf).unwrap(); + fs::write(conf.join("settings.xml"), "").unwrap(); + } + for name in ["mvnw", "mvnw.cmd", "mvnw.bat", "unrelated"] { + let path = bin.join(name); + fs::write(&path, "").unwrap(); + assert_eq!(fixture.settings_for(&path), None); + } + for name in ["mvn", "mvnd", "mvnd.exe", "mvnd.cmd", "mvnd.bat"] { + assert_eq!(fixture.settings_for(&bin.join(name)), None); + } + assert_eq!(fixture.settings_for(&home), None); + assert_eq!(fixture.settings_for(&bin), None); +} + +#[cfg(unix)] +#[test] +fn mvnd_installation_settings_follow_client_and_directory_aliases() { + // Homebrew exposes /prefix/bin/mvnd as a symlink into its versioned package; + // the global settings belong to that package, not /prefix/conf. + use std::os::unix::fs::symlink; + + let fixture = MavenSettingsFixture::new("mvnd-jdt-symlinks"); + let home = fixture.root.join("cellar/mvnd/version/libexec"); + fs::create_dir_all(home.join("bin")).unwrap(); + fs::create_dir_all(home.join("mvn/conf")).unwrap(); + let launcher = home.join("bin/mvnd"); + fs::write(&launcher, "").unwrap(); + let settings = home.join("mvn/conf/settings.xml"); + fs::write(&settings, "").unwrap(); + let prefix = fixture.root.join("prefix"); + fs::create_dir_all(prefix.join("bin")).unwrap(); + fs::create_dir_all(prefix.join("conf")).unwrap(); + fs::write(prefix.join("conf/settings.xml"), "").unwrap(); + let client_alias = prefix.join("bin/mvnd"); + symlink("../../cellar/mvnd/version/libexec/bin/mvnd", &client_alias).unwrap(); + let home_alias = fixture.root.join("current-installation"); + symlink(&home, &home_alias).unwrap(); + let bin_alias = fixture.root.join("current-launchers"); + symlink(home.join("bin"), &bin_alias).unwrap(); + let expected = settings + .canonicalize() + .unwrap() + .to_string_lossy() + .into_owned(); + for configured in [ + &client_alias, + &home_alias, + &bin_alias, + &bin_alias.join("mvnd"), + &prefix, + &prefix.join("bin"), + ] { + assert_eq!( + fixture.settings_for(configured), + Some(expected.clone()), + "{}", + configured.display() + ); + } + // Existing ancestors and settings must not rescue dangling explicit aliases. + let broken = prefix.join("bin/mvn"); + symlink("missing-client", &broken).unwrap(); + assert_eq!(fixture.settings_for(&broken), None); +} + /// Mirrors the shape of a real installation settings file: a documented /// `` plus the mirror a workspace actually resolves through. const SETTINGS_WITH_MIRROR: &str = r#" diff --git a/shared/fixtures/execution/maven-runtime-versions.json b/shared/fixtures/execution/maven-runtime-versions.json new file mode 100644 index 000000000..96166679f --- /dev/null +++ b/shared/fixtures/execution/maven-runtime-versions.json @@ -0,0 +1,10 @@ +{ + "description": "Maven and mvnd expose the embedded Apache Maven version, never the daemon banner.", + "cases": [ + { "output": "Apache Maven 3.9.11 (fixture)\nMaven home: /fixture/maven\n", "version": "3.9.11" }, + { "output": "Apache Maven Daemon (mvnd) 1.0.6 darwin-aarch64\nApache Maven 3.9.11 (fixture)\n", "version": "3.9.11" }, + { "output": "Apache Maven Daemon (mvnd) 1.0.6 windows-amd64\r\nApache Maven 4.0.0-rc-3 (fixture)\r\n", "version": "4.0.0-rc-3" }, + { "output": "Apache Maven Daemon (mvnd) 1.0.6\n", "version": null }, + { "output": "Java home is invalid\n", "version": null } + ] +} diff --git a/shared/platform-feature-matrix/features/java-runtime-discovery.json b/shared/platform-feature-matrix/features/java-runtime-discovery.json index 950a6b483..cee7cde2b 100644 --- a/shared/platform-feature-matrix/features/java-runtime-discovery.json +++ b/shared/platform-feature-matrix/features/java-runtime-discovery.json @@ -2,7 +2,7 @@ "id": "java-runtime-discovery", "area": "Java", "group": "Java/Maven", - "capability": "JDK 与 Maven 工具链发现及按项目要求自动选择 JDK", + "capability": "JDK、Maven / mvnd 工具链发现、安装设置同步及按项目要求自动选择 JDK", "macos": { "evidence": [ "macos/Sources/Lithe/Services/Java", @@ -13,7 +13,13 @@ "macos/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeProbeCache.swift", "macos/Tests/LitheTests/MacRuntimeProbeCacheTests.swift", "macos/Tests/LitheTests/ProjectRuntimeLoadingTests.swift", - "macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift" + "macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift", + "macos/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeDiscovery.swift", + "macos/Tests/LitheTests/MacRuntimeDiscoveryTests.swift", + "rust/lithe-core/src/project/maven.rs", + "rust/lithe-core/src/tests/languages.rs", + "shared/fixtures/execution/maven-runtime-versions.json", + ".agents/notes/implemented/architecture/2026-09-21-maven-settings-reach-the-language-server.md" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -23,11 +29,17 @@ "windows/tauri/src/features/maven", "windows/tauri/src/features/workspace", "windows/tauri/src-tauri/src/run.rs", - "shared/fixtures/run-configuration/automatic-java-selection.json" + "shared/fixtures/run-configuration/automatic-java-selection.json", + "windows/tauri/src/features/run/utils/run-configuration.ts", + "windows/tauri/src/features/run/utils/run-configuration.test.ts", + "rust/lithe-core/src/project/maven.rs", + "rust/lithe-core/src/tests/languages.rs", + "shared/fixtures/execution/maven-runtime-versions.json", + ".agents/notes/implemented/architecture/2026-09-21-maven-settings-reach-the-language-server.md" ], "implementationStatus": "implemented", "verificationStatus": "pending" }, "owner": "Java / Maven", - "verification": "两端安装 JDK 8/17/21:项目要求 17 且 JAVA_HOME 为 8 时验证自动选择兼容版本;核对设置、Run、Maven 目标、测试使用同一 JDK。验证显式选择不变、没有兼容版本时回退并提示,以及无要求时的平台默认顺序。 macOS 恢复已有运行配置时检查主队列仍能处理交互;同一工具与符号链接别名只探测一次,并发请求共用结果;工具文件变化或手动刷新后重新探测,失败结果可重试。验证切换项目和取消打开后旧探测结果不再应用。" + "verification": "两端安装 JDK 8/17/21:项目要求 17 且 JAVA_HOME 为 8 时验证自动选择兼容版本;核对设置、Run、Maven 目标、测试使用同一 JDK。验证显式选择不变、没有兼容版本时回退并提示,以及无要求时的平台默认顺序。 macOS 恢复已有运行配置时检查主队列仍能处理交互;同一工具与符号链接别名只探测一次,并发请求共用结果;工具文件变化或手动刷新后重新探测,失败结果可重试。验证切换项目和取消打开后旧探测结果不再应用。 两端验证 mvnd 根目录、bin 目录和启动器三种显式选择,执行仍使用 mvnd 客户端;同目录显式 mvn 保持普通 Maven 设置。自动选择保留有效 Wrapper、普通 Maven、mvnd 顺序,覆盖仅 MVND_HOME、仅 PATH、无效 MVND_HOME 回退。版本读取内嵌 Apache Maven 行,忽略 daemon 标题,使用共享版本 fixture。核对 JDT 全局设置读取 mvnd 的 mvn/conf/settings.xml,缺失时不回退 daemon conf;普通 Maven 目录缺启动器、Wrapper 和失效链接均不冒充安装,macOS 目录与客户端符号链接跟随真实安装。Core languages 测试仅验证共享设置规则;各原生系统还需以独立临时项目、仓库和 daemon 状态目录实际构建、验证 JDT 设置并清理进程,完成前两端保持待验证。" } diff --git a/windows/tauri/src-tauri/src/run.rs b/windows/tauri/src-tauri/src/run.rs index 8def554c5..634ecac8f 100644 --- a/windows/tauri/src-tauri/src/run.rs +++ b/windows/tauri/src-tauri/src/run.rs @@ -21,6 +21,9 @@ use tauri::{AppHandle, Emitter, Manager}; mod launch_arguments; +const MAVEN_LAUNCHERS: &[&str] = &["mvn.cmd", "mvn.bat", "mvn.exe", "mvn"]; +const MVND_LAUNCHERS: &[&str] = &["mvnd.exe", "mvnd.cmd", "mvnd.bat", "mvnd"]; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; const TOOLCHAIN_PROBE_TIMEOUT: Duration = Duration::from_secs(3); const PRE_LAUNCH_TIMEOUT: Duration = Duration::from_secs(600); @@ -342,6 +345,16 @@ pub fn maven_resolve_installation(root: PathBuf, override_path: Option) } fn maven_executable_without_probing(root: &Path, override_path: Option<&str>) -> Option { + maven_executable_without_probing_with_candidates(root, override_path, || { + maven_executable_candidates(None) + }) +} + +fn maven_executable_without_probing_with_candidates( + root: &Path, + override_path: Option<&str>, + candidates: impl FnOnce() -> Vec, +) -> Option { if let Some(configured) = override_path .map(str::trim) .filter(|value| !value.is_empty()) @@ -364,7 +377,7 @@ fn maven_executable_without_probing(root: &Path, override_path: Option<&str>) -> } // Wrappers were already considered above, and an unusable one must not // shadow a machine installation here. - maven_executable_candidates(Some(root)) + candidates() .into_iter() .filter(|candidate| !is_maven_wrapper(candidate)) .find(|candidate| candidate.is_file()) @@ -1437,25 +1450,48 @@ fn well_known_java_roots() -> Vec { } fn maven_executable_candidates(project_root: Option<&Path>) -> Vec { + maven_executable_candidates_with_sources( + project_root, + |key| { + std::env::var_os(key) + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + }, + lookup_on_path, + ) +} + +fn maven_executable_candidates_with_sources( + project_root: Option<&Path>, + mut environment: impl FnMut(&str) -> Option, + mut path_lookup: impl FnMut(&str) -> Option, +) -> Vec { let mut executables = Vec::new(); if let Some(root) = project_root { executables.push(root.join("mvnw.cmd")); executables.push(root.join("mvnw.bat")); executables.push(root.join("mvnw")); } - if let Ok(home) = std::env::var("MAVEN_HOME") { - let home = PathBuf::from(home); - executables.push(home.join("bin").join("mvn.cmd")); - executables.push(home.join("bin").join("mvn.bat")); - executables.push(home.join("bin").join("mvn")); + for key in ["MAVEN_HOME", "M2_HOME"] { + if let Some(home) = environment(key) { + executables.extend( + MAVEN_LAUNCHERS + .iter() + .map(|name| home.join("bin").join(name)), + ); + } + } + // An installed mvnd must not replace an ordinary Maven or project wrapper. + for name in MAVEN_LAUNCHERS { + if let Some(path) = path_lookup(name) { + executables.push(path); + } } - if let Ok(home) = std::env::var("M2_HOME") { - let home = PathBuf::from(home); - executables.push(home.join("bin").join("mvn.cmd")); - executables.push(home.join("bin").join("mvn")); + if let Some(home) = environment("MVND_HOME") { + executables.extend(custom_maven_executable_candidates(&home)); } - for name in ["mvn.cmd", "mvn.bat", "mvn.exe", "mvn"] { - if let Some(path) = lookup_on_path(name) { + for name in MVND_LAUNCHERS { + if let Some(path) = path_lookup(name) { executables.push(path); } } @@ -1466,13 +1502,17 @@ fn custom_maven_executable_candidates(path: &Path) -> Vec { if path.is_file() { return vec![path.to_path_buf()]; } - let bin = path.join("bin"); - let mut candidates = ["mvn.cmd", "mvn.bat", "mvn.exe", "mvn"] + // Accept an installation root or bin directory. Never substitute mvnd's + // embedded mvn: selecting mvnd must keep the daemon client as the executable. + [path.join("bin"), path.to_path_buf()] .into_iter() - .map(|name| bin.join(name)) - .collect::>(); - candidates.push(path.to_path_buf()); - candidates + .flat_map(|bin| { + MVND_LAUNCHERS + .iter() + .chain(MAVEN_LAUNCHERS) + .map(move |name| bin.join(name)) + }) + .collect() } pub(crate) fn probe_java_home(home: &Path) -> Option { @@ -1742,6 +1782,17 @@ pub(crate) fn resolve_maven_executable( root: &Path, working_directory: &Path, override_path: &str, +) -> Result { + resolve_maven_executable_with_candidates(root, working_directory, override_path, || { + maven_executable_candidates(None) + }) +} + +fn resolve_maven_executable_with_candidates( + root: &Path, + working_directory: &Path, + override_path: &str, + candidates: impl FnOnce() -> Vec, ) -> Result { let configured = override_path.trim(); if !configured.is_empty() { @@ -1750,14 +1801,10 @@ pub(crate) fn resolve_maven_executable( } else { root.join(configured) }; - let candidates = [ - path.clone(), - path.join("bin").join("mvn.cmd"), - path.join("bin").join("mvn.bat"), - path.join("bin").join("mvn.exe"), - path.join("bin").join("mvn"), - ]; - if let Some(found) = candidates.into_iter().find(|candidate| candidate.is_file()) { + if let Some(found) = custom_maven_executable_candidates(&path) + .into_iter() + .find(|candidate| candidate.is_file()) + { return Ok(normalize_path(&found).to_string_lossy().into_owned()); } return Err("Maven executable path does not exist.".into()); @@ -1777,7 +1824,7 @@ pub(crate) fn resolve_maven_executable( } // Match project import's filesystem-only fallback. Discovering a launcher // must never execute a wrapper or download its Maven distribution. - maven_executable_without_probing(root, None) + maven_executable_without_probing_with_candidates(root, None, candidates) .ok_or_else(|| { if saw_incomplete_wrapper { "Maven wrapper is incomplete (.mvn/wrapper/maven-wrapper.properties is missing) and no system Maven was found. Install Maven or restore the wrapper files.".into() @@ -2079,8 +2126,14 @@ fn java_version(output: &str) -> Option { fn maven_version(output: &str) -> Option { output.lines().find_map(|line| { - let rest = line.trim().strip_prefix("Apache Maven")?; - rest.split_whitespace().next().map(str::to_string) + // mvnd prints its daemon banner before the embedded Apache Maven line. + let rest = line.trim().strip_prefix("Apache Maven ")?; + let version = rest.split_whitespace().next()?; + version + .chars() + .next()? + .is_ascii_digit() + .then(|| version.to_string()) }) } @@ -2745,6 +2798,220 @@ mod tests { } } + mod mvnd { + use super::*; + + struct Fixture(PathBuf); + + impl Fixture { + fn new() -> Self { + Self(temp_project()) + } + + fn file(&self, relative: &str) -> PathBuf { + let path = self.0.join(relative); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, "fixture; never execute").unwrap(); + path + } + + fn candidates( + &self, + homes: &[(&str, &str)], + clients: &[(&str, &Path)], + ) -> Vec { + maven_executable_candidates_with_sources( + Some(&self.0), + |key| { + homes + .iter() + .find(|(name, _)| *name == key) + .map(|(_, home)| self.0.join(home)) + }, + |key| { + clients + .iter() + .find(|(name, _)| *name == key) + .map(|(_, path)| path.to_path_buf()) + }, + ) + } + + fn assert_resolution(&self, candidates: Vec, expected: &Path) { + let import = + maven_executable_without_probing_with_candidates(&self.0, None, || { + candidates.clone() + }); + let execution = + resolve_maven_executable_with_candidates(&self.0, &self.0, "", || candidates); + let expected = normalize_path(expected).to_string_lossy().into_owned(); + assert_eq!(import.as_deref(), Some(expected.as_str())); + assert_eq!(execution, Ok(expected)); + } + } + + impl Drop for Fixture { + fn drop(&mut self) { + if let Err(error) = fs::remove_dir_all(&self.0) { + eprintln!("Could not clean mvnd fixture: {error}"); + } + } + } + + #[test] + fn explicit_home_bin_and_clients_keep_mvnd_instead_of_embedded_maven() { + let fixture = Fixture::new(); + for name in MVND_LAUNCHERS { + let home = fixture.0.join(name); + let client = fixture.file(&format!("{name}/bin/{name}")); + fixture.file(&format!("{name}/mvn/bin/mvn.cmd")); + for selected in [home.clone(), home.join("bin"), client.clone()] { + let selected = selected.to_string_lossy(); + let expected = normalize_path(&client).to_string_lossy().into_owned(); + assert_eq!( + maven_executable_without_probing_with_candidates( + &fixture.0, + Some(&selected), + Vec::new + ), + Some(expected.clone()) + ); + assert_eq!( + resolve_maven_executable_with_candidates( + &fixture.0, + &fixture.0, + &selected, + Vec::new + ), + Ok(expected) + ); + let discovered = + discover_maven_candidates(vec![client.clone()], Some(&selected), |path| { + path.is_file().then(|| MavenRuntime { + executable_path: normalize_path(path) + .to_string_lossy() + .into_owned(), + version: "3.9.9".into(), + }) + }); + assert_eq!(discovered.len(), 1, "deduplicate explicitly selected mvnd"); + assert_eq!( + Path::new(&discovered[0].executable_path), + normalize_path(&client) + ); + } + } + } + + #[test] + fn invalid_explicit_mvnd_does_not_fall_back_to_another_installation() { + let fixture = Fixture::new(); + let client = fixture.file("maven/bin/mvn.cmd"); + // Even a distribution containing only the embedded Maven is invalid. + fixture.file("broken-mvnd/mvn/bin/mvn.cmd"); + for selected in ["missing", "broken-mvnd"] { + assert_eq!( + maven_executable_without_probing_with_candidates( + &fixture.0, + Some(selected), + || vec![client.clone()] + ), + None + ); + assert!(resolve_maven_executable_with_candidates( + &fixture.0, + &fixture.0, + selected, + || vec![client.clone()] + ) + .is_err()); + } + } + + #[test] + fn mvnd_home_only_resolves_the_daemon_client() { + let fixture = Fixture::new(); + let client = fixture.file("mvnd/bin/mvnd.exe"); + fixture.file("mvnd/mvn/bin/mvn.cmd"); + fixture.assert_resolution(fixture.candidates(&[("MVND_HOME", "mvnd")], &[]), &client); + } + + #[test] + fn path_only_resolves_each_mvnd_launcher() { + let fixture = Fixture::new(); + for name in MVND_LAUNCHERS { + let client = fixture.file(&format!("path/{name}")); + fixture.assert_resolution(fixture.candidates(&[], &[(name, &client)]), &client); + } + } + + #[test] + fn ordinary_maven_precedes_mvnd_from_home_or_path() { + let fixture = Fixture::new(); + let ordinary = fixture.file("maven/bin/mvn.cmd"); + let daemon = fixture.file("mvnd/bin/mvnd.exe"); + for key in ["MAVEN_HOME", "M2_HOME"] { + fixture.assert_resolution( + fixture.candidates( + &[(key, "maven"), ("MVND_HOME", "mvnd")], + &[("mvnd.exe", &daemon)], + ), + &ordinary, + ); + } + fixture.assert_resolution( + fixture.candidates( + &[("MVND_HOME", "mvnd")], + &[("mvn.cmd", &ordinary), ("mvnd.exe", &daemon)], + ), + &ordinary, + ); + } + + #[test] + fn usable_wrapper_precedes_maven_and_mvnd() { + let fixture = Fixture::new(); + let wrapper = fixture.file("mvnw.cmd"); + fixture.file(".mvn/wrapper/maven-wrapper.properties"); + fixture.file("maven/bin/mvn.cmd"); + fixture.file("mvnd/bin/mvnd.exe"); + fixture.assert_resolution( + fixture.candidates(&[("MAVEN_HOME", "maven"), ("MVND_HOME", "mvnd")], &[]), + &wrapper, + ); + } + + #[test] + fn invalid_mvnd_home_and_incomplete_wrapper_fall_back_to_path() { + let fixture = Fixture::new(); + fixture.file("mvnw.cmd"); + let client = fixture.file("path/mvnd.cmd"); + fixture.assert_resolution( + fixture.candidates(&[("MVND_HOME", "missing")], &[("mvnd.cmd", &client)]), + &client, + ); + assert_eq!( + maven_executable_without_probing_with_candidates(&fixture.0, None, || fixture + .candidates(&[("MVND_HOME", "missing")], &[])), + None + ); + } + + #[test] + fn version_uses_embedded_maven_instead_of_daemon_banner() { + let fixture: serde_json::Value = serde_json::from_str(include_str!( + "../../../../shared/fixtures/execution/maven-runtime-versions.json" + )) + .expect("valid Maven version fixture"); + for case in fixture["cases"].as_array().expect("version cases") { + assert_eq!( + maven_version(case["output"].as_str().expect("version output")).as_deref(), + case["version"].as_str() + ); + } + } + } + #[test] fn java_listing_skips_maven_target() { let root = temp_project(); diff --git a/windows/tauri/src/features/run/utils/run-configuration.test.ts b/windows/tauri/src/features/run/utils/run-configuration.test.ts index a5094715d..d9c4018ba 100644 --- a/windows/tauri/src/features/run/utils/run-configuration.test.ts +++ b/windows/tauri/src/features/run/utils/run-configuration.test.ts @@ -184,6 +184,48 @@ describe("run configuration mapping", () => { ]); }); + for (const launcher of ["mvnd.exe", "mvnd.cmd", "mvnd.bat", "mvnd", "mvn.cmd", "mvn.bat", "mvn.exe", "mvn"]) { + for (const selection of ["d:/tools/maven/", "d:/tools/maven/BIN/", `d:/tools/maven/bin/${launcher.toUpperCase()}`]) { + test(`matches Maven selection ${selection} to ${launcher}`, () => { + expect(selectedToolchainCandidates( + { + java: [], + maven: [ + { executablePath: "C:/Other/bin/mvn.cmd", version: "3.8.8" }, + { executablePath: `D:\\Tools\\Maven\\bin\\${launcher}`, version: "3.9.9" }, + ], + runtimes: [], + }, + { + javaHomePath: "", + mavenExecutablePath: selection, + mavenJavaHomePath: "", + runtimeExecutablePaths: {}, + }, + )).toEqual([{ id: "project-maven", type: "maven", version: "3.9.9", vendor: "" }]); + }); + } + } + + test("does not match a selected mvnd home to its embedded Maven or an unrelated client", () => { + expect(selectedToolchainCandidates( + { + java: [], + maven: [ + { executablePath: "D:/Tools/mvnd/mvn/bin/mvn.cmd", version: "3.9.9" }, + { executablePath: "D:/Other/bin/mvnd.exe", version: "3.9.9" }, + ], + runtimes: [], + }, + { + javaHomePath: "", + mavenExecutablePath: "D:/Tools/mvnd", + mavenJavaHomePath: "", + runtimeExecutablePaths: {}, + }, + )).toEqual([]); + }); + test("shows Maven settings from the configuration requirement without discovery", () => { expect(configurationUsesMaven({ toolchains: { java: "project-jdk", maven: "project-maven" } })).toBe(true); expect(configurationUsesMaven({ toolchains: { java: "project-jdk" } })).toBe(false); diff --git a/windows/tauri/src/features/run/utils/run-configuration.ts b/windows/tauri/src/features/run/utils/run-configuration.ts index b99a5146a..34ec62462 100644 --- a/windows/tauri/src/features/run/utils/run-configuration.ts +++ b/windows/tauri/src/features/run/utils/run-configuration.ts @@ -282,8 +282,9 @@ export function effectiveRuntimeExecutablePaths( function mavenSelectionMatchesRuntime(selection: string, executable: string): boolean { if (sameWindowsPath(selection, executable)) return true; const home = selection.replace(/[\\/]+$/, ""); - return ["mvn.cmd", "mvn.bat", "mvn.exe", "mvn"].some((name) => - sameWindowsPath(`${home}/bin/${name}`, executable)); + return ["mvnd.exe", "mvnd.cmd", "mvnd.bat", "mvnd", "mvn.cmd", "mvn.bat", "mvn.exe", "mvn"].some((name) => + sameWindowsPath(`${home}/bin/${name}`, executable) + || sameWindowsPath(`${home}/${name}`, executable)); } function sameWindowsPath(left: string, right: string): boolean { diff --git a/windows/tauri/src/i18n/locale.ts b/windows/tauri/src/i18n/locale.ts index 10f6217e2..745418775 100644 --- a/windows/tauri/src/i18n/locale.ts +++ b/windows/tauri/src/i18n/locale.ts @@ -1602,7 +1602,7 @@ const catalogs = { "run.jdkHomeHint": "Leave empty to use the detected JDK.", "run.mavenExecutable": "Maven home or executable", "run.mavenExecutableHint": - "Choose a Maven home directory, or leave empty to use the project wrapper or detected Maven.", + "Choose a Maven or mvnd home, bin directory, or executable. Leave empty to use the project wrapper, detected Maven, then mvnd.", "run.nodeExecutable": "Node.js executable", "run.nodeExecutableHint": "Choose node.exe, or leave empty to use the detected Node.js runtime.", @@ -1838,7 +1838,7 @@ const catalogs = { "settings.run.description": "Select a service or task to configure its arguments, environment and project-environment overrides. Changes apply after Save.", "settings.project.openProject": "Open a project to configure its JDK and Maven.", "settings.project.scope": "Defaults for this project on this computer. Run configurations inherit these values unless explicitly overridden. Leave a path empty to use automatic selection.", - "settings.project.mavenAutomatic": "Automatic: project Maven Wrapper, then detected Maven. You can also enter a Maven home or executable path.", + "settings.project.mavenAutomatic": "Automatic: project Maven Wrapper, detected Maven, then mvnd. You can also enter a Maven or mvnd home, bin directory, or executable path.", "settings.project.useProjectJdk": "Use project JDK", "settings.project.detected": "Detected installations", "settings.project.saved": "Project environment saved.", @@ -6166,7 +6166,7 @@ const catalogs = { "run.jdkHome": "JDK 主目录", "run.jdkHomeHint": "留空则使用自动检测到的 JDK。", "run.mavenExecutable": "Maven 主目录 / 可执行文件", - "run.mavenExecutableHint": "可选择 Maven 主目录;留空则使用项目 Wrapper 或系统 Maven。", + "run.mavenExecutableHint": "可选择 Maven 或 mvnd 的主目录、bin 目录或可执行文件;留空则依次使用项目 Wrapper、检测到的 Maven、mvnd。", "run.nodeExecutable": "Node.js 可执行文件", "run.nodeExecutableHint": "可选择 node.exe;留空则使用自动检测到的 Node.js 运行时。", "run.mavenJdkHome": "Maven JDK 主目录", @@ -6392,7 +6392,7 @@ const catalogs = { "settings.run.description": "选择服务或任务,配置启动参数、环境变量和项目环境覆盖项;点击保存后生效。", "settings.project.openProject": "打开项目后,可在这里配置项目 JDK 和 Maven。", "settings.project.scope": "仅保存在当前电脑,作用于当前项目。运行配置默认继承这些值,单独设置的覆盖值保持不变。路径留空时使用自动选择。", - "settings.project.mavenAutomatic": "自动:优先使用项目 Maven Wrapper,其次使用检测到的 Maven。也可以填写 Maven 主目录或可执行文件路径。", + "settings.project.mavenAutomatic": "自动:依次使用项目 Maven Wrapper、检测到的 Maven、mvnd。也可以填写 Maven 或 mvnd 的主目录、bin 目录或可执行文件路径。", "settings.project.useProjectJdk": "使用项目 JDK", "settings.project.detected": "检测到的安装", "settings.project.saved": "项目环境已保存。",