diff --git a/README.md b/README.md index 3f12eb6..1a9fcc0 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ - интерактивный интерфейс профилей и настроек; - Tauri 2 native shell с безопасной командой `native_host`; - сохранение памяти профиля в локальном каталоге данных приложения; +- безопасное обнаружение установленной Java (включая `JAVA_HOME`) перед запуском; - адаптивное окно для Windows, Linux и macOS; - базовая структура, в которую будут добавлены манифесты, проверка файлов, Java 21 и запуск Minecraft. diff --git a/src-tauri/src/java.rs b/src-tauri/src/java.rs new file mode 100644 index 0000000..6d7b6b3 --- /dev/null +++ b/src-tauri/src/java.rs @@ -0,0 +1,89 @@ +use serde::Serialize; +use std::{env, path::PathBuf, process::Command}; + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct JavaInstallation { + pub executable: String, + pub major: u8, + pub version: String, +} + +/// Finds a usable Java runtime without modifying the machine. +/// +/// The launcher will later use this result to decide whether Java 21 needs to +/// be bundled. We deliberately check JAVA_HOME first: it is explicit and makes +/// development installations predictable across all supported platforms. +pub fn detect() -> Option { + candidates().into_iter().find_map(check_candidate) +} + +fn candidates() -> Vec { + let executable = if cfg!(target_os = "windows") { + "java.exe" + } else { + "java" + }; + + let mut candidates = Vec::new(); + if let Some(java_home) = env::var_os("JAVA_HOME") { + candidates.push(PathBuf::from(java_home).join("bin").join(executable)); + } + candidates.push(PathBuf::from(executable)); + candidates +} + +fn check_candidate(candidate: PathBuf) -> Option { + let output = Command::new(&candidate).arg("-version").output().ok()?; + if !output.status.success() { + return None; + } + + let source = format!( + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let version = parse_version(&source)?; + let major = parse_major(&version)?; + Some(JavaInstallation { + executable: candidate.display().to_string(), + major, + version, + }) +} + +fn parse_version(source: &str) -> Option { + let marker = "version \""; + let start = source.find(marker)? + marker.len(); + let remainder = &source[start..]; + let end = remainder.find('"')?; + Some(remainder[..end].to_owned()) +} + +fn parse_major(version: &str) -> Option { + let mut segments = version.split('.'); + let first = segments.next()?.parse::().ok()?; + if first == 1 { + segments.next()?.parse::().ok() + } else { + Some(first) + } +} + +#[cfg(test)] +mod tests { + use super::{parse_major, parse_version}; + + #[test] + fn parses_modern_java_version() { + let output = "openjdk version \"21.0.8\" 2025-07-15"; + assert_eq!(parse_version(output).as_deref(), Some("21.0.8")); + assert_eq!(parse_major("21.0.8"), Some(21)); + } + + #[test] + fn parses_legacy_java_version() { + assert_eq!(parse_major("1.8.0_452"), Some(8)); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 62b0817..509cd52 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,3 +1,4 @@ +mod java; mod manifest; mod profile; mod settings; @@ -29,6 +30,12 @@ fn native_host(app: AppHandle) -> Result { }) } +/// Detects an existing Java installation. This is read-only and never downloads Java. +#[tauri::command] +fn detect_java() -> Option { + java::detect() +} + /// Validates an untrusted profile manifest before any file is downloaded. #[tauri::command] fn validate_manifest(manifest_json: String) -> Result<(), String> { @@ -97,6 +104,7 @@ pub fn run() { tauri::Builder::default() .invoke_handler(tauri::generate_handler![ native_host, + detect_java, validate_manifest, inspect_profile, sync_profile, diff --git a/src/main.tsx b/src/main.tsx index 1529162..9991454 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -45,6 +45,12 @@ type NativeSettings = { memoryMb: number } +type JavaInstallation = { + executable: string + major: number + version: string +} + const servers: Server[] = [ { id: 'aoc', @@ -76,6 +82,7 @@ function App() { const [settingsOpen, setSettingsOpen] = useState(false) const [ram, setRam] = useState(6) const [nativeHost, setNativeHost] = useState(null) + const [java, setJava] = useState(undefined) useEffect(() => { if (progress === null) return @@ -96,6 +103,9 @@ function App() { invoke('load_settings') .then((settings) => setRam(settings.memoryMb / 1024)) .catch(() => undefined) + invoke('detect_java') + .then(setJava) + .catch(() => setJava(null)) }, []) const updateRam = (memoryGb: number) => { @@ -248,7 +258,22 @@ function App() { updateRam(Number(e.target.value))} /> Для Aeronautics рекомендуется 6 ГБ - +
+ Папка игры + {nativeHost ? 'В каталоге лаунчера' : 'Определяется…'} +
+
+ Java + + {java === undefined + ? 'Проверяем…' + : java && java.major >= 21 + ? `Java ${java.major} найдена` + : java + ? `Нужна Java 21 · найдена ${java.major}` + : 'Java не найдена'} + +
{nativeHost ? `Данные лаунчера: ${nativeHost.dataDir}` : 'Java 21 будет управляться лаунчером автоматически.'} diff --git a/src/styles.css b/src/styles.css index 632f010..ce1eac1 100644 --- a/src/styles.css +++ b/src/styles.css @@ -141,6 +141,9 @@ button:focus-visible, input:focus-visible { outline: 2px solid var(--green); out .setting-row span { display: flex; align-items: center; gap: 11px; font-size: 12px; } .setting-row svg { width: 17px; color: #677068; } .setting-row:hover { color: white; } +.setting-row.static { cursor: default; } +.setting-row.static:hover { color: #abb3ac; } +.setting-row small { color: #7f8a80; font-size: 11px; } .drawer-note { position: absolute; left: 28px; right: 28px; bottom: 26px; padding: 13px 15px; background: #17231a; color: #8fb193; border-radius: 10px; font-size: 10px; } @media (max-width: 1160px) {